PYTHON DSA The Problem Solving Playbook
0%
⤓
Interactive Book · Works Offline · Single HTML File

PYTHON DSA
The Problem Solving Playbook

From “I know Python” to “I can solve DSA problems”.

You already know Python.
Now learn how to think like a problem solver.
30+
Learning Chapters
100+
Practice Problems
18
Core Patterns
Interactive
Quizzes & Challenges
Visual
Dry Runs & Diagrams
Beginner → Strong
Guided Journey
The DSA Problem-Solving Loop — the spine of this whole book
Understand
→
Examples
→
Constraints
→
Brute Force
→
Pattern
→
Optimize
→
Implement
→
Test
→
Reflect

Every chapter trains one step of this loop — until it becomes instinct.

PART 0

How to Use This Book

You already know Python. What you are missing is the skill the syntax never taught you: turning an unfamiliar problem into code on purpose, not by luck.

Most people who struggle with DSA can already write a for loop perfectly well. Watch them open a fresh problem and they freeze — not because the code is hard, but because nobody ever showed them what happens between reading the statement and typing the first line. That in-between space is what this book trains, and this part explains how to train with it.

What DSA Actually Is

DSA is short for Data Structures and Algorithms — and both halves are about one thing: the cost of operations.

Data structure — how data is arranged

A choice about layout: list, dictionary, set, stack. Layout decides which questions are cheap. A list answers nums[3] instantly but answers is 7 in here? slowly. A set answers the opposite way.

Algorithm — the order of steps

A finite recipe: check every element, halve the search space, grow a window. The recipe decides how many operations run as the input grows.

A concrete taste before any theory: find one name among 1,000,000 records. If the records are scattered, you check up to 1,000,000 of them. If they are sorted, binary search finds the name in at most 20 checks — same data, same Python, 50,000× fewer operations. The organization did that, not raw speed. That gap is what the next 30+ parts teach you to create on demand.

Concept

DSA is not a syntax course and not a math course. It is the study of how the cost of operations grows with input size — and how to choose arrangements and step-orders that keep that growth under control.

What Problem Solving Actually Means

Problem solving is translation. A statement arrives as fuzzy English — "return how many scores are passing" — and must leave as precise operations: look at each score once, compare it with 50, count the hits. Then the operations become code, almost mechanically.

Fuzzy English
→
Precise operations
→
Python code
The whole book lives in the first arrow. Part 2 turns it into a repeatable routine.

People who "freeze" are stuck at step one: the statement never became operations. People who "just see the answer" are not smarter — they translate faster because they have seen the shapes before.

Why Knowing Syntax Is Not Enough

Take the classic: given a list of numbers and a target, decide whether two of them add up to the target. Every piece of Python you need — for, if, in, set — you already know. Yet the solution feels invisible. Here is the mismatch:

What you know (syntax)
  • how to loop over a list
  • how to test a condition
  • how to build a dictionary or set
What the problem demands (decisions)
  • what to loop over — the numbers? the pairs?
  • what to remember between iterations
  • whether 10⁵ elements makes your plan too slow

The gap is decision-making, and decisions are exactly what patterns compress. For two-sum, one decision changes everything: for each number, the partner I need is target − x — so remember the numbers I have seen. That single observation converts an O(n²) all-pairs scan into an O(n) lookup sweep. Nobody derives that from syntax. Everybody who has seen "find a partner → use a seen-set" recognizes it instantly.

The Book's Core Philosophy

"Pattern Recognition is the Highest Form of Intelligence."

Expert problem solvers do not think harder than you. They think less, because a statement's clues trigger patterns they have already built. This book is engineered to hand you those patterns — and, more importantly, the skill of noticing the clues that point at them.

Why Pattern Recognition Matters

There are thousands of DSA problems but only about 20 recurring shapes underneath them. A few examples of the compression you are about to gain:

  • "find a pair that sums to target" → store what you've seen in a set, look up the complement.
  • "longest contiguous run satisfying X" → sliding window: grow right, shrink left when broken.
  • "sorted, find this value" → binary search: halve the space every step.

Recognizing the shape turns a 40-minute struggle into a 5-minute construction. Not recognizing it leaves you enumerating possibilities by hand — the freeze, formalized.

~20
patterns cover most interview problems
100+
problems in this book, each tagged by pattern
1
loop you will reuse on every single problem

The DSA Problem-Solving Loop

Every worked problem in this book follows the same 12-stage loop. Internalize the stages once and you will never face a blank page again — because "solve the problem" becomes "run the loop".

Understand
↓
Examples
↓
Constraints
↓
Brute Force
↓
Pattern Recognition
↓
Choose Data Structure
↓
Optimize
↓
Complexity
↓
Implement
↓
Test
↓
Debug
↓
Reflect
Reflect feeds back into Understand — the loop is a loop, not a line.
StageQuestion it answersTypical output
UnderstandWhat exactly is being asked?one-sentence restatement
ExamplesWhat does "correct" look like?a hand-traced example, including the tricky one
ConstraintsHow big can the input get?a target complexity, e.g. "needs O(n log n) or better"
Brute ForceWhat is the dumbest correct approach?a slow-but-right plan, written down
Pattern RecognitionWhat have I seen that looks like this?a candidate pattern (set? window? two pointers?)
Choose Data StructureWhat must I store to avoid redoing work?list / set / dict / stack choice
OptimizeWhich repeated work can disappear?the improved plan and what it no longer re-does
ComplexityWill it finish within the constraints?time and space in O(...) notation
ImplementCan the plan become plain Python?a first working version
TestDoes it survive samples and my own edges?verified behavior, including empty/single-element inputs
DebugWhere does reality diverge from the plan?a fixed version and the reason it broke
ReflectWhich clue would have pointed here faster?one line in your pattern journal
Q1. A solution passes the sample inputs but times out on the hidden large test. Which loop stage was skipped?
The code is correct — it just cannot handle the input size. Checking complexity against constraints (stage 8) would have caught it before a single line was written.

How to Approach an Unseen Problem

The loop compresses into six moves when you are live-solving. This is the reflex this book drills until it is boring — boring is the goal.

1

Restate it in one sentence. If you cannot, you do not understand it yet — reread until you can.

2

Walk the examples by hand. Include the weird one. Say why each output is what it is.

3

Read the constraints. Big n kills slow plans before you write them (Part 1 gives you the table).

4

State a brute force out loud. A correct-but-slow plan on paper beats a fast plan in your imagination.

5

Point at the repeated work. Whatever the brute force does twice is your optimization target.

6

Match a pattern, then code. Choose the data structure that remembers the repeated work for you.

How to Work Through Each Chapter

This book is built to be used, not skimmed. The chapters are designed around these seven habits:

1

Read the why before the how. Every concept opens with the observation that motivates it. If you skip to the code, you get code without triggers — which is memorization with extra steps.

2

Predict before each code block. Cover the code, decide what you would write, then compare. Being wrong on purpose is the fastest way to learn the right thing.

3

Trace every dry run by hand. Move real values through the boxes with a finger. Hand-tracing builds the execution simulator in your head — the thing that catches bugs before running.

4

Attempt practice problems before opening reveals. Hint → Approach → Solution is a ladder, not a shortcut. Climb it in order.

5

Answer every quiz honestly. A wrong click that teaches you something is worth more than a lucky right one. The explanations appear after you answer — read them either way.

6

Revisit the revision checklist days later. The checkboxes remember their state, so they double as spaced-repetition cards. Re-ticking from memory a few days later is the point.

7

Keep a pattern journal. One line per problem: clue → pattern → mistake I made. By the last part it will be the most valuable file you own.

How to Practice (and How Not To)

Practice only works if it is deliberate: problems slightly above your level, a genuine attempt before any reveal, and a post-mortem of every miss. Ten problems done this way beat a hundred read passively.

Different readers arrive with different gaps. Here is what this book does about each:

If this is you…What this book does about it
"I can code, but I freeze at new problems."Every problem starts from the statement's clues, never from code. Part 2 gives you a fixed reading routine so a blank page becomes a checklist.
"I understand solutions when I read them, but can't produce them."Solutions are always hidden behind Hint → Approach → Solution reveals, and you are asked to predict before each one. Reading becomes re-deriving.
"I memorize solutions and forget them next week."The book teaches ~20 patterns plus a clue → pattern table. Twenty anchors are memorable; two hundred solutions are not.
"My brute force works but I can't make it faster."Key problems show Brute → Better → Optimal and name the repeated work that disappeared at each step — so "optimizing" stops being a mystery.
"I see constraints like n ≤ 10⁵ and have no idea if my idea is fast enough."Part 1 builds complexity intuition from zero, including a "what fits in one second" table you will use for the rest of the book.
"My code passes the samples but fails hidden tests."Worked problems carry Edge Cases lists, and you are told to invent your own test before coding — the hidden tests live in your head already.

How to Use Hints

Hints are load-bearing walls, not decorations. Used wrong, they rot your progress; used right, they are the fastest learning tool in the book.

  1. Attempt for 10–15 focused minutes first. Writing a brute force counts as attempting. Staring does not.
  2. Open Hint 1 only. Read it, close it, and re-attempt from what you now know.
  3. Still stuck? Open the Approach. Then restate it in your own words before touching the code.
  4. Solution comes last — and after reading it, close the book and rebuild it from an empty file.
Common Mistake

The recognition illusion: nodding along as you read a solution feels exactly like learning, and it is not. The only reliable test is a blank editor 24 hours later. If you cannot rebuild it, you recognized it — you did not learn it.

How to Read Solutions Correctly

When you do read a solution — the book's or anyone's — read it actively:

  1. Read the key idea first, not the code. One sentence: what does this solution remember that brute force kept forgetting?
  2. Predict the next line before you read it. You will be surprised how often you are right — and the misses show exactly where your model is broken.
  3. Name the repeated work that disappeared. That sentence is the actual lesson; the code is just its proof.
  4. Close the book and re-implement from an empty file. No peeking. Struggle here is the learning.
  5. Diff your version against the original and note the one place you diverged. That note is your pattern journal entry.
Tip

If you can re-implement a solution a day later, you own the pattern behind it. If you cannot, re-read only the key idea — not the code — and try again.

Why Memorizing Code Is Bad

Memorized solutions fail for a structural reason, not a memory-power reason: problems are remixes. Change the array to a string, the equality to an inequality, the "any pair" to the "best window", and a memorized solution matches nothing — while a pattern survives every remix.

❌ The memory strategy

Store 200+ solutions as code. Every remix invalidates one. Recall needs exact triggers, so anything unfamiliar reads as "never seen before" → freeze.

✅ The pattern strategy

Store ~20 shapes plus the clue table that maps statements to shapes. Remixes are handled because the shape — not the surface — is what you memorized.

This is also why the book never shows you a solution before an attempt, and why every chapter ends with a Pattern Recognition section instead of a summary of code.

How to Build Intuition

Intuition is not a gift; it is the residue of many correct repetitions. Four habits produce it faster than volume alone:

  • Hand-trace before running. Predict the output of every dry run on paper first. Your mental interpreter gets sharper each time.
  • Predict, then check. Guess the complexity, the next window state, the next probe — then verify. Corrected predictions stick.
  • Explain out loud. If you cannot say an idea in one sentence to an imaginary friend, you do not own it yet. The attempt to compress is where understanding happens.
  • Space it out. Return to each part's checklist after a few days. The checkboxes persist, so use them as a free flashcard system.
Pattern Insight

The meta-pattern of this whole book: statements leak clues, and clues point at patterns. "Pair that sums to…" leaks a seen-set. "Contiguous" leaks a window. "Sorted" leaks binary search. From the next part onward, every chapter ends by asking the same question: what clue in the statement reveals this pattern?

What You Learned

  • DSA is the study of operation cost: data structures decide which questions are cheap, algorithms decide how many operations run.
  • Problem solving is translation: fuzzy English → precise operations → code. The first arrow is the skill.
  • Syntax knowledge is necessary but not sufficient — the missing piece is decision-making under constraints.
  • Patterns compress thousands of problems into ~20 shapes; recognizing clues is the book's core skill.
  • The Problem-Solving Loop (12 stages) turns "solve the problem" into a repeatable procedure.
  • Practice is deliberate: attempt → hint ladder → re-implement → reflect. Memorizing solutions is structurally doomed.

Pattern Recognition

This part is the pattern behind the patterns: when a problem looks unfamiliar, do not search for code — search for a shape. Ask "what have I seen that looks like this?", check the statement's clues, and let the shape pick the data structure. Every chapter from here on will hand you one more shape plus the clues that trigger it.

Complexity Summary

A preview of the ladder you will fully learn in Part 1 — keep it as a bookmark in your head:

ClassOne-line meaningTypical example
O(1)same work no matter the sizedictionary lookup
O(log n)halve the space each stepbinary search
O(n)touch everything oncefind the max
O(n log n)sorting speedsorted(nums) + one pass
O(n²)every element vs every elementnested loops over pairs

Common Mistakes

  • Reading the solution before making a genuine attempt — the recognition illusion.
  • Memorizing code instead of naming the shape behind it.
  • Skipping constraints and discovering incompatibility after 40 lines of code.
  • Passive reading: no predicting, no hand-tracing, no re-implementation.
  • Treating quizzes as trivia instead of using wrong answers to find broken mental models.
  • Never reflecting — solving 100 problems without a pattern journal and staying at zero patterns.

Mini Quiz

Q2. You have been stuck on a practice problem for 3 focused minutes. What is the best next move, by this book's rules?
A written brute force makes the repeated work visible — and repeated work is exactly what optimizations are made from. Hints come after 10–15 minutes, solutions last.
Q3. When should you open a hint?
The ladder is: genuine attempt → Hint 1 → re-attempt → Approach → re-derive → Solution last. Each rung keeps you doing the cognitive work yourself.
Q4. What is the correct way to read a solution you could not produce?
The lesson is the idea, not the code. Re-implementation from a blank editor is the only test that separates recognition from learning.
Q5. Why do memorized solutions fail on remixed problems?
Change array → string, equality → inequality, and the memorized code matches nothing. Patterns survive remixes because they capture the shape underneath.

Practice Problems

0.1 Spot the Repeated Work

🟢 L1 · Understand

Read this code and answer in one sentence: what work repeats?

Python
def has_duplicates(nums):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] == nums[j]:
                return True
    return False
Show Hint

Watch the inner loop: for each single i, how many comparisons does it make?

Show Approach

Count what the loops touch. For each element, the inner loop re-compares it against every element after it — every pair of positions gets compared exactly once.

Show Solution

The repeated work is all-pairs comparison: n elements each compared with up to n − 1 others, roughly n²/2 comparisons total. The shape "compare everything with everything" is the signature of nested loops — and the first thing we will optimize away in Part 1.

0.2 Translate the Sentence

🟢 L1 · Understand

Problem statement: "Given a list of temperatures, return how many days were below freezing (below 0)." Extract: the input, the output, and the per-element operation. Then write the function.

Show Hint

Answer the first three loop questions: what is given? what must be returned? what happens to each element?

Show Approach

Input: a list of integers. Output: one integer (a count). Operation per element: a single comparison t < 0. That is "touch everything once" → a single loop with a counter.

Show Solution
Python
def count_below_freezing(temps):
    count = 0
    for t in temps:       # touch each day once
        if t < 0:         # one comparison per element
            count += 1
    return count

Notice what you just did: statement → operations → code. That translation pipeline is Part 2's whole subject.

0.3 Feasibility Check

🟡 L2 · Apply

A friend's solution is O(n²) — every element compared with every other. The problem allows n up to 100,000. Using the preview table in Part 1's "one second" spirit: is this acceptable, and what complexity should they target?

Show Hint

Compute the actual operation count: what is 100,000 squared?

Show Approach

n² = 10⁵ × 10⁵ = 10¹⁰ operations. A Python-level loop manages roughly 10⁷ simple operations per second, so 10¹⁰ ÷ 10⁷ = 10³ seconds — about 17 minutes. Dead on arrival.

Show Solution

O(n²) is not acceptable at n = 10⁵. The target is O(n log n) (~1.7 million operations, comfortably under a second) or O(n). Memorize this checkpoint: n ≤ 10⁵ bans O(n²) — it returns as a reading skill in Part 2.

Challenge Problem

0.4 Run the Full Loop

🟠 L3 · Recognize
Problem

Given a list of daily temperatures, return the index of the highest temperature. If the highest value appears twice, return the smallest index. Do not just solve it — run every stage of the loop, in order, on paper.

Input / Output

temps = [12, 31, 7, 31] → 1 (first 31, not the one at index 3)

Show Hint 1

Stage by stage: restate → trace the example → why does index 1 win over index 3? → describe the dumbest correct approach → what does the brute force compare twice?

Show Approach

Restate: one scan must find the biggest value and its earliest position. The brute force compares every element against every other — all-pairs again. But notice: while walking left to right, once you know the best so far, you never need to look at it again. Track two things — the best value and its index — and update only when you see something strictly greater. Strict > is what preserves the first index on ties. That is the pattern: single pass with a running best, the workhorse shape of O(n) scans.

Show Solution
Python
def index_of_highest(temps):
    best_index = 0
    for i in range(1, len(temps)):
        if temps[i] > temps[best_index]:   # strict > keeps the FIRST tie
            best_index = i
    return best_index
Dry Run — temps = [12, 31, 7, 31]
i=1: 31 > 12 → best_index = 1
i=2: 7  > 31? ❌ no change
i=3: 31 > 31? ❌ strict > rejects the tie
return 1 ✅

Complexity: time O(n) — one pass; space O(1) — two variables.

Pattern Recognition
Pattern Insight

"Best value in one sweep" ⇒ running best: keep the best-so-far, compare each newcomer against it. The tie rule ("smallest index") is a clue about strict vs non-strict comparison — statements love hiding requirements inside words like "first" and "smallest index".

Revision Checklist

PART 1

Complexity Analysis

How to know your code will finish before you run it — no formal math, just growth shapes and real numbers.

A student writes a function that checks every pair of elements. On 100 test records it returns in 0.02 seconds — correct, clean, done. Then it meets the real dataset: 100,000 records. Same code, same machine, now roughly five and a half hours. Nothing was broken; the plan was simply shaped wrong for the input size. Complexity analysis is the skill of seeing that shape before running the code — and it is the single most used tool in the rest of this book.

Input Size and Operation Count

Two definitions, then everything follows:

  • Input size (n) — how much data arrives. For a list it is len(nums); for a string, the number of characters.
  • Operation — one cheap step: a comparison, an addition, an assignment, a dictionary lookup. We count them in units, not microseconds.

Why count operations instead of measuring seconds? Because seconds are a property of your laptop; operation counts are a property of your plan. The same plan runs at different speeds on different hardware — but its operation count is identical everywhere, and that is what we want to reason about.

Python
def find_max(nums):
    best = nums[0]
    for x in nums:        # the body runs n times (n = len(nums))
        if x > best:      # 1 comparison per element
            best = x
    return best

Here the loop body executes once per element, so comparisons ≈ n. Double the input → double the work. That relationship — work as a function of n — is all that complexity is.

Concept

Complexity answers one question: when the input grows, how does the work grow? Not "how fast is this code today" — "how does it behave as n explodes".

Time vs Space Complexity

Every plan costs two resources, both measured against n:

Time complexity

How the number of operations grows. A nested loop doing n×n comparisons costs more as n grows than a single pass.

Space complexity

How the extra memory grows. A seen set that can hold up to n items uses memory that grows with n; two counter variables do not.

They trade off constantly: the fastest duplicate-checker remembers everything it saw (O(n) extra space); the most memory-frugal one re-compares pairs instead (O(n²) time). You will meet this exact trade-off later in this part.

Big-O in One Breath (No Math)

Big-O notation is just a label for a growth shape. Three rules produce every simplification you will ever need:

  1. Keep the fastest-growing term. As n explodes, the biggest term decides everything else becomes irrelevant.
  2. Drop constants. 3n, 100n and n/2 all grow the same way — linearly. They all get the label O(n).
  3. Describe the worst case. Plans are judged by their bad day, because that is what the hidden test will hand you.

So 3n² + 100n + 7 comparisons is labeled O(n²): the n² term dominates once n is large. And n/2 comparisons is O(n), not O(n/2) — Big-O has no constants.

Q1. A loop does 3n² + 100n + 7 operations. What is its Big-O?
Keep the fastest-growing term (n²), drop constants (3 → gone). For n = 10,000 the n² term contributes 10⁸ operations while 100n contributes only 10⁶ — the n² term is 99% of the story.

The Growth Ladder

From best to worst, there are seven shapes you must recognize on sight. At n = 1,000 their operation counts differ by orders of magnitude:

O(1)
O(log n)
O(n)
O(n log n)
O(n²)
O(2ⁿ)
O(n!)
Relative work at n = 1,000: from ~1 operation to numbers longer than this page. Red = danger zone.

O(1) — Constant

Intuition: the work does not depend on n at all. Input of 10 items or 10 billion — the same few operations run.

Real-World Analogy

Finding the first book on a shelf. A shelf of 20 books or 20,000 — reaching for position one takes the same motion.

Python
first = nums[0]          # direct index: O(1)
size = len(nums)         # length is stored, not counted: O(1)
if target in seen_set:   # set/dict lookup: O(1) on average
    print("found")
Common Mistake

O(1) means constant, not instant. An O(1) step that internally does a million things is still constant — and still slow. Also: 7 in some_list on a list is O(n) (it scans); 7 in some_set is O(1). The bracket shape looks the same; the cost does not.

✅ Acceptable: always. ⚠️ Dangerous: never — this is the floor every design aims for.

O(log n) — Halving

Intuition: each step throws away half of what remains. 1,000,000 → 500,000 → 250,000 → … → 1 in about 20 steps. Doubling the input adds just one more step.

Real-World Analogy

The 1-to-100 guessing game. "Higher… lower…" — every guess eliminates half the range. Worst case: 7 guesses, no matter which number was chosen. A phone book works the same way: open the middle, keep the right half.

Python
def binary_search(nums, target):   # nums is sorted
    lo, hi = 0, len(nums) - 1
    while lo <= hi:
        mid = (lo + hi) // 2       # look at the middle...
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            lo = mid + 1           # ...or throw away the LEFT half
        else:
            hi = mid - 1           # ...or throw away the RIGHT half
    return -1
Dry Run — nums = [2, 5, 8, 12, 16, 23, 38, 56], target = 16
lo=0 hi=7 → mid=3 → nums[3]=12 < 16 → keep RIGHT half, lo=4
lo=4 hi=7 → mid=5 → nums[5]=23 > 16 → keep LEFT half,  hi=4
lo=4 hi=4 → mid=4 → nums[4]=16 ✅ found — 3 probes, not 8 checks
Q2. Binary search on 1,000,000 sorted items needs at most about how many probes?
Each probe halves the space: 2²⁰ = 1,048,576 ≥ 1,000,000, so about 20 probes. The precondition is everything: the data must be sorted, or halving is impossible.
Common Mistake

Confusing which loops are logarithmic. for i in range(n) is O(n). But while n > 1: n = n // 2 is O(log n) — the variable shrinks by a factor, not by a step. Base of the log (2 or 10) never matters in Big-O; only "halving behavior" does.

✅ Acceptable: always — it is the second-best shape that exists. ⚠️ Dangerous: effectively never.

O(n) — Linear

Intuition: touch every element once. Work grows in lockstep with input: double n, double the work.

Real-World Analogy

Counting the audience by walking seat by seat. Twice as many seats, twice as long. There is no shortcut if you must be sure about every seat.

Python
def total(nums):
    t = 0
    for x in nums:     # n iterations → O(n)
        t += x
    return t
Common Mistake

Two loops in a row is O(n) + O(n) = O(2n) = O(n) — constants vanish. Nested loops is O(n) × O(n) = O(n²). Sequence adds, nesting multiplies. Mixing these two up is the most common error beginners make when counting.

✅ Acceptable: nearly always — and often unavoidable, since reading the input already costs O(n). ⚠️ Dangerous: when it runs inside another loop (that quietly makes O(n²)) or when n is in the hundreds of millions.

O(n log n) — Sort Speed

Intuition: n items each doing log n work — the fingerprint of good sorting. Almost always appears as "sort first, then one linear pass".

Real-World Analogy

Organizing 1,000 exam papers by repeatedly splitting the pile and merging sorted halves. Each paper is touched ~log n times (10 levels for 1,000 papers), so total handling ≈ n × log n.

Python
def smallest_gap(nums):
    ordered = sorted(nums)              # O(n log n) — the dominant cost
    best = float("inf")
    for i in range(1, len(ordered)):    # O(n) scan on top of it
        gap = ordered[i] - ordered[i - 1]
        if gap < best:
            best = gap
    return best

The sort (n log n) dwarfs the scan (n), so the whole function is O(n log n). Neighbors in a sorted array sit next to each other — that single fact turns "compare all pairs" into "compare adjacent pairs".

Common Mistake

Treating sorted(nums) as free. It costs O(n log n) every time it runs — calling it inside a loop silently adds another log factor per element. Ask of every line: what does this cost?

✅ Acceptable: virtually always — this is the practical "good" ceiling for big-n problems. ⚠️ Dangerous: only if you sort repeatedly inside another loop.

O(n²) — All Pairs

Intuition: every element interacts with every other — nested loops. The signature shape is for i … for j ….

Real-World Analogy

Handshakes at a party: 100 guests each shake hands with 99 others ≈ 10,000 handshakes. 1,000 guests → about a million. The work grows like the square of the crowd.

Python
def count_pairs(nums):
    count = 0
    for i in range(len(nums)):         # n times
        for j in range(i + 1, len(nums)):  # ~n/2 times each → n * n/2
            count += 1
    return count
n=10
n=100
n=1,000
n=10,000
O(n²) operation counts: 100 → 10,000 → 1,000,000 → 100,000,000. Ten times the input costs one hundred times the work.
Common Mistake

The hidden quadratic: an innocent-looking O(n) step inside a loop. if x in nums — where nums is a list — scans the whole list. Inside a loop over n elements that is O(n²) with no visible nesting. Also: 10⁸ operations "feels fine" on a toy input, then dies the moment a constraint says n ≤ 10⁵ (that would be 10¹⁰).

✅ Acceptable: when n is small — roughly n ≤ 3,000 fits in a second of Python. Fine when constraints guarantee it. ⚠️ Dangerous: n ≥ 10,000 and growing; forbidden when constraints say 10⁵ or more.

O(2ⁿ) — Exponential

Intuition: every new element doubles the possibilities. This is the natural cost of exploring all choices of "take it or leave it" — all subsets of n items. Add one item, and the entire previous workload repeats twice.

Real-World Analogy

A pizza with n optional toppings. Each topping doubles the menu: 10 toppings → 1,024 possible pizzas, 20 → 1,048,576, 30 → over a billion. No kitchen enumerates a billion pizzas.

Python
def all_subsets(items):
    if not items:
        return [[]]                      # one subset: the empty one
    rest = all_subsets(items[1:])        # subsets without the first item
    return rest + [[items[0]] + s for s in rest]
    # ...plus the same subsets again, each forced to include the first item
n=10
n=20
n=25
n=30
2¹⁰ = 1K → 2²⁰ ≈ 1M → 2²⁵ ≈ 33M → 2³⁰ ≈ 1B. Every +5 to n multiplies the work by ~32.
Common Mistake

Writing a recursive function that branches twice on each element and not noticing. The doubling test exposes it: if input going from 20 to 40 makes the runtime not 2×, not 4×, but ~2²⁰× worse — that is exponential. Never submit it for n above ~25.

✅ Acceptable: only when n is tiny — n ≤ ~22 keeps you near a few million operations. Constraints that say n ≤ 20 are a hint that exponential was intended. ⚠️ Dangerous: n ≥ 25 — 2²⁵ is already 33 million, and it only gets worse exponentially.

O(n!) — Every Arrangement

Intuition: all possible orderings of n things. n choices for slot one, n−1 for slot two, and so on: n × (n−1) × … × 1. It grows even faster than 2ⁿ.

Real-World Analogy

Arranging books on a shelf. 10 books → 3,628,800 possible shelves. 13 books → more arrangements than seconds in a decade. Adding one book multiplies the chaos by the whole current count.

Python
from itertools import permutations

def arrangement_count(books):
    return len(list(permutations(books)))   # n! arrangements exist
Common Mistake

Reaching for permutations to "check all orders" when a single well-chosen pass would do. If the problem says n ≤ 10 or less, brute-forcing arrangements may pass; at n = 13 even generating the list is hopeless.

✅ Acceptable: n ≤ ~10, and only when nothing smarter exists. ⚠️ Dangerous: n ≥ 12 — 12! ≈ 479 million, 13! ≈ 6.2 billion.

Counting Operations in Loops

You do not need formulas to get a loop's complexity — you need three shapes:

Code shapeWorkComplexity
one loop over n itemsnO(n)
two loops in sequencen + n = 2nO(n)
loop inside a loopn × nO(n²)
the loop variable halves (or ×2) each steplog nO(log n)
a loop whose body itself sorts n itemsn × n log nO(n log n) per outer step — watch out

The three core shapes in code:

Single loop → O(n)
Python
for x in nums:   # runs n times
    print(x)
Halving loop → O(log n)
Python
while n > 1:     # n → n/2 → n/4 → …
    n = n // 2   # ~log2(n) steps
Python
def count_comparisons(nums):
    count = 0
    for i in range(len(nums)):              # n
        for j in range(i + 1, len(nums)):   # n/2 on average
            if nums[i] == nums[j]:
                count += 1
    return count        # total ≈ n²/2 comparisons → O(n²)

Half of n² is still O(n²) — constants disappear. This is the handshake count from the party: n(n−1)/2.

Trace It Yourself — predict, then check

🟡 L2 · Apply

Part A. How many steps does this take for n = 100? Predict by hand before reading the reveal.

Python
def halvings(n):
    steps = 0
    while n > 1:
        n = n // 2
        steps += 1
    return steps

Part B. What is the complexity of for i in range(n): for j in range(i): … — and roughly how many iterations is that for n = 1,000?

Show Hint

Part A: write the chain of values n takes: 100 → 50 → … Part B: the inner loop runs 0, 1, 2, …, n−1 times. Add those up.

Show Approach

Part A: 100 → 50 → 25 → 12 → 6 → 3 → 1: six halvings, so 6 steps ≈ log₂(100) ≈ 6.6. Part B: the sum 0+1+…+999 = 999,000/2 ≈ 500,000 = n²/2 — the handshake count again.

Show Solution

A: returns 6. Halving loops always take ⌈log₂ n⌉ steps. B: O(n²), about 500,000 iterations for n = 1,000 — and 50,000,000,000 for n = 1,000,000, which is exactly why n² collapses at scale.

The Growth Table

The numbers behind every "will it finish?" decision you will ever make. log₂ rounded to one decimal:

nlog₂ nnn log₂ nn²2ⁿ
103.310331001,024
1006.610066410,000≈10³⁰
1,00010.01,0009,9661,000,000≈10³⁰¹
10,00013.310,000132,877100,000,000≈10³⁰¹⁰
100,00016.6100,0001,660,96410,000,000,000≈10³⁰¹⁰³

Read the last column slowly: for n = 100, 2ⁿ ≈ 10³⁰ operations. At 10⁷ operations per second that is hundreds of thousands of times the age of the universe. Not slow — physically impossible. Meanwhile O(n log n) at n = 100,000 is about 1.7 million operations: a fraction of a second. Same problem, same data — only the shape changed.

Important

This table is why constraints exist. When Part 2 teaches you to read a statement, the line n ≤ 10⁵ is not decoration — it is a promise about which columns are allowed: the n² column (10¹⁰) is banned, the n log n column (≈1.7 × 10⁶) is welcome.

What Can I Handle in ~1 Second?

Rounded rules of thumb, assuming a Python-level loop manages about 10⁷ simple operations per second (a typical laptop; other languages shift the constant, never the shapes):

ComplexityLargest n in ~1 secondThat looks like…
O(1)no limita dictionary lookup, whatever n is
O(log n)effectively any nbinary search over 10¹⁸ items ≈ 60 probes
O(n)≈ 10,000,000one pass over ten million items
O(n log n)≈ 500,000sorting half a million items
O(n²)≈ 3,000all pairs among three thousand items
O(2ⁿ)≈ 23all subsets of 23 items (≈ 8.4 million)
O(n!)≈ 10all arrangements of 10 items (≈ 3.6 million)

Constants vary by machine and by what counts as an "operation" — treat this as a map of magnitudes, not a stopwatch. C might buy you 100× n; it will never buy you a bigger shape.

Tip

Memorize three borders: n ≤ 20 → 2ⁿ is fine · n ≤ 3,000 → n² is fine · n ≤ 10⁵ → need O(n log n) or O(n). Almost every feasibility decision in interviews sits on one of these borders.

The Danger Ranking

The whole ladder, best to worst, as one glanceable chain. Gold nodes are the shapes that look harmless on small inputs and detonate at scale:

O(1)
→
O(log n)
→
O(n)
→
O(n log n)
→
O(n²)
→
O(2ⁿ)
→
O(n!)
Left side: friends that scale. Right side (gold): traps that scale against you. When you design, walk left.

Space Complexity — the Other Bill

Memory has the same conversation with n that time does. Total memory = the input you were given + the extra space your algorithm creates. We report the extra space, because the input is handed to you regardless of what you do — the interesting question is what your plan adds on top.

Python
def sum_of_squares(nums):
    total = 0
    for x in nums:
        total += x * x     # one extra variable → O(1) extra space
    return total
Python
def squares_list(nums):
    return [x * x for x in nums]   # a new n-item list → O(n) extra space

Same input, same n, very different memory bill. Here is the time/space trade-off from earlier, in full — three ways to detect duplicates:

Python
def has_duplicates_brute(nums):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] == nums[j]:   # compare every pair
                return True
    return False

Time O(n²) · Extra space O(1). Remembers nothing — pays with re-comparison.

Python
def has_duplicates_sorted(nums):
    ordered = sorted(nums)              # sorted copy: O(n) extra memory
    for i in range(1, len(ordered)):    # duplicates become neighbors
        if ordered[i] == ordered[i - 1]:
            return True
    return False

Time O(n log n) · Extra space O(n) in Python (sorted() builds a copy). Buys a linear scan by paying for order.

Python
def has_duplicates_set(nums):
    seen = set()
    for x in nums:
        if x in seen:    # O(1) average lookup
            return True
        seen.add(x)
    return False

Time O(n) · Extra space O(n). Remembers every element — pays with memory.

The trade-off in one line: the set spends memory to avoid re-comparing. Most optimizations in this book are exactly this deal.
Important

Recursion has a space bill too: every nested call occupies the call stack. A recursion that dives n levels deep uses O(n) extra space even if it creates no lists. When a statement says "O(1) extra space", it is usually pointing you at an in-place, loop-based approach.

Q3. A function loops over nums (n items) and, for each element, checks x in nums_copy where nums_copy is a list of n items. Total time complexity?
in on a list is a hidden O(n) scan. Inside an O(n) loop: O(n) × O(n) = O(n²) — the hidden quadratic. A set would make each lookup O(1) and the whole thing O(n).

What You Learned

  • Complexity measures how work grows with n, counted in operations — not seconds.
  • Big-O labels growth shapes: keep the dominant term, drop constants, judge the worst case.
  • The ladder: O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!) — each step is a different universe.
  • Loops broadcast their cost: single = n, sequential = add, nested = multiply, halving = log n.
  • Constraints imply a budget: n ≤ 10⁵ means O(n log n)/O(n), never O(n²).
  • Space complexity is extra memory beyond the input; time and space trade off (the seen-set deal).

Pattern Recognition

Complexity is itself a pattern-reading skill: constraints are clues about the intended shape. n ≤ 20 whispers "exponential is fine"; n ≤ 3,000 allows n²; n ≤ 10⁵ demands O(n log n) or better; a sorted array invites O(log n). From now on, read every constraint as a hint about the target complexity — and let that target prune your design choices before you write a line.

Complexity Summary

ClassGrows likeCode shape that produces itSafe n (Python, ~1 s)
O(1)constantindexing, dict/set lookup, arithmeticany
O(log n)halvingsearch space cut in half per stepany realistic
O(n)linearsingle pass~10,000,000
O(n log n)sort speedsort + one pass~500,000
O(n²)all pairsloop inside a loop (or in list inside a loop)~3,000
O(2ⁿ)doubling choicestake-it-or-leave-it recursion~23
O(n!)all ordersgenerating permutations~10

Common Mistakes

  • Arguing about constants (2n vs 3n) while missing that the shape is n².
  • Using x in some_list inside a loop — the hidden quadratic.
  • Calling sorted() inside a loop and forgetting it costs O(n log n) each time.
  • Adding sequential loops as if they nested: O(n) + O(n) is O(n), not O(n²).
  • Measuring the best case instead of the worst case.
  • Believing O(1) means fast — it means constant.
  • Reporting total memory instead of extra space (or forgetting the recursion stack).

Mini Quiz

Q4. What is the complexity of for i in range(n): for j in range(n): print(i, j)?
Nesting multiplies: n × n = n². The inner loop runs to completion for every single outer iteration.
Q5. while i < n: i = i * 2 — starting from i = 1. Complexity?
i takes the values 1, 2, 4, 8, … and doubles until it passes n — about log₂(n) steps. Multiplying (or dividing) the loop variable by a constant each step is the log n signature.
Q6. What is the average cost of x in seen when seen is a set?
Sets and dicts are hash tables: membership is constant time on average regardless of size. This single fact powers most O(n) optimizations in this book.
Q7. The constraint is n ≤ 100,000. Which complexity finishes in about a second in Python?
O(n) ≈ 10⁵ operations — trivial. O(n²) would be 10¹⁰ (minutes), 2ⁿ and n! are astronomically impossible. Target O(n log n) (~1.7 × 10⁶) or O(n).

Practice Problems

1.1 Sequential Loops

🟢 L1 · Understand

What is the total complexity of a function that first sums a list (one loop) and then finds its max (another loop)?

Show Hint

Do the loops run inside each other, or one after the other?

Show Approach

Sequence adds: n + n = 2n. Big-O drops constants, so 2n → n.

Show Solution

O(n). Two full passes in sequence are still one linear shape. Nesting would have multiplied — that is the only case that jumps to n².

1.2 Find the Hidden Quadratic

🟡 L2 · Apply

This code has no nested loops written down. What is its complexity, and why?

Python
def count_repeats(nums):
    repeats = 0
    for i in range(len(nums)):     # O(n)
        if nums[i] in nums[:i]:    # ??? each time
            repeats += 1
    return repeats
Show Hint

What does in do when the thing on the right is a list (here, a slice)? And what does slicing nums[:i] itself cost?

Show Approach

in on a list scans it: O(i). The slice nums[:i] also copies i elements: O(i). Both happen inside an O(n) loop → total ≈ n²/2.

Show Solution

O(n²) time (and the slicing also churns memory). The fix is the seen-set pattern: keep a set of elements seen so far — lookups drop to O(1), the whole function to O(n), at the price of O(n) extra space.

1.3 Same Result, Different Space Bill

🟡 L2 · Apply

Two ways to reverse a list. State the time and extra space of each.

Python
# Version A
reversed_copy = nums[::-1]

# Version B
def reverse_in_place(nums):
    left, right = 0, len(nums) - 1
    while left < right:
        nums[left], nums[right] = nums[right], nums[left]
        left += 1
        right -= 1
    return nums
Show Hint

Ask of each version: what new memory does it create, and how big can it get?

Show Approach

A builds a brand-new n-item list. B swaps pairs inside the original list, touching only two index variables.

Show Solution

A: time O(n), extra space O(n) — a full copy. B: time O(n), extra space O(1) — swaps in place. Same result, ten million inputs differ: A duplicates the data, B does not. This in-place two-pointer shape returns constantly in later parts.

Challenge Problem

1.4 Feasibility Report — Two Sum at Scale

🟠 L3 · Recognize
Problem

Given a list of up to n = 100,000 integers and a target, decide whether any two of them add up to the target. You will not be graded on the answer — you will be graded on the feasibility report: analyze the brute force, prove it dies at this n, and design what survives.

Key Observation

For each number x, the only partner that works is target − x. The brute force rediscovers that partner by re-scanning; a set remembers it.

Show Hint

Brute force first: how many pairs? Then run that count against the "one second" table. Then ask: what would you need to remember to check each element exactly once?

Show Approach

Checking every pair (i, j) is n(n−1)/2 ≈ 5 × 10⁹ comparisons at n = 10⁵ — at 10⁷ ops/sec that is ~500 seconds. Dead. But "have I already seen target − x?" is a membership question, and membership in a set is O(1). One pass, one lookup per element: O(n) ≈ 10⁵ operations, comfortably under a second. The price: the set can grow to n items — O(n) extra space.

Show Solution
Python
def two_sum_exists_brute(nums, target):
    n = len(nums)                    # O(n²) — dies at n = 10^5
    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return True
    return False
Python
def two_sum_exists(nums, target):
    seen = set()
    for x in nums:
        if target - x in seen:   # the partner was already stored
            return True
        seen.add(x)
    return False

Report: brute O(n²) time / O(1) space → ~5 × 10⁹ ops → fails. Set version O(n) time / O(n) space → ~10⁵ ops → passes in well under a second. The repeated work that disappeared: re-scanning for partners, replaced by an O(1) memory lookup.

Complexity

Optimal: time O(n) · extra space O(n) — the classic trade.

Pattern Recognition
Pattern Insight

"Find a pair with a property" ⇒ think hash set of what you have seen. The clue is the word pair: pairs scream "for each x, some partner" — and a partner lookup is a membership test, the set's home turf. This is the most reused pattern in the entire book.

Revision Checklist

PART 2

How to Read a DSA Problem

Most wrong answers are reading failures, not coding failures. This part installs a 7-question routine that turns any statement into a plan.

Ask anyone who failed a DSA attempt what went wrong and they rarely say "my loop had a bug". They say "I solved the wrong problem" — missed the word contiguous, assumed the values were unique, returned the subarray when a length was asked for. Reading is a skill with its own technique, and it can be drilled exactly like code can.

Where Reading Goes Wrong

Three classic misreads, each one a missing clue:

  • Missed "contiguous" — in longest substring without repeating characters, "pwke" is forbidden because it has gaps. Solve for subsequences instead and the whole approach is wrong.
  • Missed "sorted" — a sorted array permits binary search and two pointers. Ignore the word and you will build an O(n) scan where O(log n) was the point.
  • Assumed uniqueness — your "find the duplicate" logic that breaks when [1, 2, 2, 2] appears, because the statement never said values occur once.

None of these are intelligence failures. They are process failures: the reader never asked the questions that would have surfaced the clues. Here is the process.

The Problem Translation Framework

Every statement travels the same pipeline on its way to becoming code. Your job is to walk it consciously, stage by stage, instead of jumping from the first node to the last:

English Problem
↓
Input
↓
Output
↓
Observation
↓
Pattern
↓
Algorithm
↓
Code
Code is the last step, not the first. Four of the seven stages happen before any Python exists.
  • English Problem → Input: what exactly arrives, and of what type and size?
  • Input → Output: what exactly must come back — a count? the value? the index? a boolean?
  • Output → Observation: what property of the data makes the output computable faster than brute force?
  • Observation → Pattern: which known shape uses that property?
  • Pattern → Algorithm: how does the shape behave on this data, step by step?
  • Algorithm → Code: only now — translation into Python is mechanical.

The Seven Reading Questions

The pipeline is walked by asking seven questions, in order, every time. They take under two minutes on a small problem and save twenty.

1

What is given? Name the input precisely: a list of what? a string of which characters? How big can it be? Write it as a function signature with real example values.

2

What must be returned? The most-skipped question. A length, not the substring. An index, not the value. A boolean, not the pair. The return type decides half your design.

3

What constraints exist? Convert n limits into a complexity budget using Part 1's table. Also note promises: "sorted", "unique", "non-empty", "O(1) extra space".

4

What do the examples tell me? Trace each one by hand and ask why the output is what it is. The weird example is there on purpose — it is showing you an edge case for free.

5

What is the simplest solution? Describe the brute force in one sentence, even if slow. It defines correctness — and its weaknesses point straight at the optimization.

6

Where is repeated work happening? The brute force redoes something: re-scanning, re-summing, re-comparing. That repeated work is the optimization target — name it explicitly.

7

Which known pattern does it resemble? Check the statement's keywords against your pattern table (below). Commit to a shape, predict its complexity, and check it against the budget from question 3.

Tip

Write your answers down. Three bullet points — given / return / repeated work — turn a vague fog into a checklist. Unwritten thoughts evaporate; written observations compound.

Keywords That Reveal Patterns

Statements leak their shapes through specific words. This table is the single most valuable lookup in the book — with practice you will run it unconsciously:

Keyword / phraseWhat it tells you
sortedbinary search, or two pointers from both ends
count pairs such that…hashing — for each x, look up its required partner in O(1)
contiguous subarray / substringsliding window, or prefix sums (window needs a grow/shrink condition)
subsequenceorder kept but gaps allowed → not a sliding window; think greedy or track-best-so-far
top k / k largest / k most frequentheap — or sort everything if k is huge
minimum / maximum (the best choice)try greedy first (make the locally best move); if greedy fails, track-best or DP-lite
first occurrence / position of x (sorted)binary search for the boundary
anagram / same characterscount characters with a dictionary and compare counts
in place / O(1) extra spacetwo pointers and swapping — no auxiliary structures
pair that sums to targethash set of seen values (unsorted) / two pointers (sorted)
unique / distinctset membership tracking
range sum, sum of elements i…j, many queriesprefix sums — precompute once, answer each query in O(1)
matching brackets / most recent / undostack — last in, first out
level by level / fewest steps in a gridBFS (covered later in the book)
Q1. A statement says: "Given a sorted array, return the position of target." Which pattern does the keyword point to?
Sorted is the precondition that makes halving legal — each probe can safely discard half the array. A linear scan would work but ignores the clue the statement handed you.

Reading Constraints

Constraints are budgets, and Part 1's "one second" table is the exchange rate. The most important conversion:

Important

n ≤ 10⁵ kills O(n²). n² = 10¹⁰ operations ≈ 17 minutes of pure Python-level operations at 10⁷ ops/sec — far over the ~1 second budget. The same n runs in O(n log n) with ≈ 1.7 × 10⁶ operations (a blink) or O(n) with 10⁵ (faster still). Whenever you see n in the hundred-thousands, your plan must be O(n log n) or better — no exceptions.

Constraints leak more than time budgets. Each kind tells you something different:

ConstraintWhat it tells you
n ≤ 10⁵ or moreO(n²) is banned; target O(n log n) or O(n)
n ≤ 20exponential O(2ⁿ) is acceptable — the setter is inviting it
values up to 10⁹do not count with an array of size 10⁹ — use a dictionary
only lowercase lettersat most 26 distinct values — a fixed-size count works
"O(1) extra space"in-place, two pointers, no auxiliary set or list
"the answer always exists"no empty/impossible handling needed — simpler code
"return any valid answer"you can stop at the first solution found

Squeezing Examples for Clues

Examples are not confirmation — they are evidence. Work them like this:

  1. Reverse-engineer. For each example, explain why that output follows from that input. If you cannot, you have misread something.
  2. Watch the weird one. Statements put their trap in the last example — a repeated value, an empty case, a boundary. That example is a free test you did not have to invent.
  3. Invent one more. Build a tiny input of your own (3–4 elements) and hand-solve it before coding. If your mental answer and the code's disagree later, you have caught a bug for free.
  4. Hand-trace the mechanism. Walk your candidate idea through the smallest example, writing the state at each step. If the trace is painful, the idea is complicated — expect a simpler shape.

Writing Observations

An observation is a single written sentence about the problem's structure — not a plan, not code. After reading, aim for 2–3. They are the raw material every pattern is built from.

✅ Good observations
  • "For each x, the partner I need is target − x — that is a lookup, not a search."
  • "In a sorted array, if the sum is too big, only moving the right pointer helps."
  • "Adding an element to the end only changes the sum by that element — old sums are reusable."

Each one names reusable work or structure.

❌ Bad observations
  • "It's about arrays." (true of everything, useless)
  • "Maybe a nested loop will work." (a plan fragment, not a structure)
  • "I think I've seen this before." (a feeling — which problem? which clue?)

Vague feelings cannot be built on; concrete mechanisms can.

Finding Hidden Structure

Sometimes the crucial fact is never stated — it lives in the examples. Hunt for it deliberately:

  • Implied order. Examples [1, 3, 4, 9] → the array is sorted even if no word says so. That unlocks binary search.
  • Bounded values. All values are 0–100 → counting with a 101-slot array beats a dictionary.
  • Tie-breaking clues. The examples show the first of two equal answers chosen → comparisons must be strict.
  • Monotonic behavior. As one quantity grows, the answer only moves one way → two pointers become safe.
  • What never appears. No example shows an empty input, but the constraints don't forbid it — you just found a test case to handle.
Tip

After tracing examples, ask: "what property do all the inputs share that the statement never mentioned?" That question surfaces hidden structure more reliably than any other.

Converting Words into Operations

DSA statements are written in a near-code dialect. Once you hear the dialect, translation is word-for-word:

Statement saysCode operation
"for each element…"a loop
"the largest / smallest…"a best-so-far variable updated by comparison
"have we seen X before?"set membership: x in seen
"how many times does X appear"a counter, or counts[x] += 1 in a dict
"keep removing while invalid"a while loop shrinking from one end
"a pair of…"a second remembered item — pointer, or a seen-structure
"in place"swaps instead of new lists
"running total / so far"state updated each iteration, never restarted
"longest / shortest…"track a best length; update it at each valid step

Before writing real logic, write the skeleton — signature, inputs, outputs, one-line idea. It costs thirty seconds and catches misreads while they are still cheap:

Python
def solve(nums, k):
    # INPUT : nums = list of n ints, k = int
    # OUTPUT: the COUNT of qualifying items (an int) — not the items
    # IDEA  : single pass, count elements satisfying the condition
    count = 0
    # ... logic goes here, after the reading is done
    return count

Worked Example — Reading "Longest Substring Without Repeating Characters"

One full reading pass, exactly as the seven questions demand. Notice: we stop after predicting the pattern. No solution code — reading comes first, always.

Given a string s, find the length of the longest substring that contains no repeating characters.

Examples:

  • s = "abcabcbb" → 3 (the answer is "abc"; note the string then repeats)
  • s = "bbbbb" → 1 (the longest run without repeats is a single "b")
  • s = "pwwkew" → 3 — note: the answer must be a substring; "pwke" is a subsequence and does not count.

The seven questions, answered on paper:

  1. What is given? One string of letters. Size unstated → assume up to ~10⁵ characters and budget accordingly.
  2. What must be returned? A number — the length. Not the substring itself. Easy to solve the wrong problem here.
  3. Constraints? Nothing explicit, so the 10⁵ assumption bans O(n²)-style re-checking; we need roughly O(n).
  4. What do the examples say? "bbbbb" → 1: a repeat kills the run. "pwwkew" → 3 with a note about contiguity: gaps are forbidden — that note is a formal clue, not decoration.
  5. Simplest solution? Examine every substring and test it for uniqueness. Vastly many substrings, each re-checked: far too slow.
  6. Where is the repeated work? When the window slides one character right, the brute force re-examines characters it has already seen. The set of "characters currently inside" is re-derived from scratch every time — that memory is what a data structure could keep.
  7. Which pattern? A region that grows on the right and shrinks from the left when a condition breaks, over a contiguous span: sliding window territory.

The pipeline, filled in:

  • English Problem: longest stretch of the string with all-distinct characters.
  • Input: string s, up to ~10⁵ characters.
  • Output: one integer — the best length.
  • Observation: "characters inside the current stretch" can be kept in a set and updated incrementally — add the newcomer, drop from the left while it is a duplicate. No re-scanning.
  • Pattern: sliding window + seen-set. (contiguous longest shrink on repeat)
  • Algorithm: expand the right edge one character at a time; whenever the newcomer is already inside, shrink from the left until it is not; after each step the window is valid, so update the best length.
Python
def length_of_longest_substring(s):
    # INPUT : string s, up to ~10^5 chars
    # OUTPUT: one integer — the LENGTH, not the substring
    # IDEA  : slide a window; a set remembers chars inside it
    # seen  = set of chars currently in the window
    # left  = window start   best = best length so far
    # for each right char: shrink while duplicate, then grow
    return 0   # skeleton only — the reading came first

The prediction — written before any real code exists:

  • Pattern: sliding window with a set of in-window characters.
  • Time: O(n). Each character enters the window once and leaves at most once — 2n steps total, no re-scanning.
  • Space: O(min(n, alphabet size)) for the set.
  • Feasibility check: O(n) at n = 10⁵ → 10⁵ operations. Passes the budget with room to spare.
  • Tests I already own: "bbbbb" (shrinking every step), "" (empty — return 0), "abba" (the mean one: after the second "b", the left edge must jump past the first "b", not restart the window — the examples did not warn me, my invented test did).
Idea Trace — s = "pwwkew" (no code, just the window evolving)
right→p : window "p"                best = 1
right→w : window "pw"               best = 2
right→w : repeat! shrink → "w"      best = 2
right→k : window "wk"               best = 2
right→e : window "wke"              best = 3 ✅
right→w : repeat! shrink → "kew"    best = 3
answer predicted: 3

Notice what just happened: the problem is not solved yet, but the pattern, complexity, and traps are all known. Writing the function from here is mechanical.

Pattern Insight

"Contiguous" + "longest" + a condition that breaks locally ⇒ sliding window. Three separate words in the statement each contributed a piece: contiguity ruled out subsequences, "longest" meant track-a-best-length, and the note about "pwke" confirmed the window must stay gap-free. The pattern was assembled entirely from reading — before a single line of logic.

What You Learned

  • Most failures are reading failures: missed keywords, wrong return types, ignored constraints.
  • The Problem Translation pipeline: English → Input → Output → Observation → Pattern → Algorithm → Code.
  • The seven reading questions, from "what is given?" to "which pattern is this?", asked in order, every time.
  • Statements leak their shapes: sorted → binary search, contiguous → window, top k → heap, pair → hash set.
  • Constraints are budgets: n ≤ 10⁵ bans O(n²) and invites O(n log n)/O(n).
  • Observations are written, concrete sentences about reusable work — not vibes.
  • Reading ends with a prediction: pattern, complexity, and your own test cases — before coding.

Pattern Recognition

This part is the book's bridge: every later chapter hands you a new pattern, and this is the routine that decides when to deploy it. When you meet an unseen problem, run the seven questions, scan for keywords against the table, and predict the shape out loud. The habit to build now: never let the first thing you write be code.

Complexity Summary

The keyword table, compressed into complexity targets — these are the budgets you predict when reading:

Clue in statementPattern to predictTypical time
sorted + find a valuebinary searchO(log n)
pair with a propertyhash set / dict of seen itemsO(n)
contiguous span + conditionsliding windowO(n)
top k / k most frequentheap (or sort)O(n log k)
best local choicegreedyO(n log n)
frequency questionsdict countingO(n)

Common Mistakes

  • Coding before pinning down the exact return type — length vs value vs index vs boolean.
  • Ignoring constraints and discovering the complexity budget only via timeout.
  • Skimming examples instead of reverse-engineering them — the weird one is free evidence.
  • Keeping observations in your head; unwritten observations do not survive contact with code.
  • Assuming unstated properties (unique values, non-empty input) that the examples never confirmed.
  • Jumping from "English Problem" straight to "Code" and skipping the four middle stages.

Mini Quiz

Q2. A statement asks for "the k most frequent words". Which structure does the clue point to?
"Most frequent" is a counting job (dict) and "k most" is a top-k job (heap). Two keywords, two structures — the statement hands you the whole design.
Q3. The constraint reads n ≤ 200,000. Your first idea is an O(n²) double loop. Verdict?
n² = (2 × 10⁵)² = 4 × 10¹⁰ operations — minutes of pure Python-level work. The constraint is a budget: at this size only logarithmic/linear shapes pass.
Q4. "Longest contiguous subarray with at most k zeros." Which pattern does the phrasing predict?
Contiguous span + "longest" + a condition that breaks locally (zero count exceeding k) — the exact window signature: grow right, shrink left while invalid.
Q5. What is the very first thing to pin down when reading any problem?
Input and output are the anchors: everything else — observations, patterns, budgets — hangs off them. A wrong return type invalidates a correct algorithm.

Practice Problems

2.1 Keyword Scan

🟢 L1 · Understand

"Given a list of n words and an integer k, return the k most frequent words, any order." List every keyword and the structure it points to. Do not write code.

Show Hint

Two phrases carry all the information: "most frequent" and "k".

Show Approach

"Most frequent" → count occurrences → dictionary. "k most" → selecting k winners out of n → heap (or a full sort). Also note: "any order" relaxes output requirements — a hint that multiple answers are valid.

Show Solution

Keywords: most frequent → dict counting · k most → heap. Predicted shape: count with a dict, then extract the top k. Predicted time: O(n + n log k). No code needed — the reading was the exercise.

2.2 Constraint Check

🟡 L2 · Apply

A statement has n ≤ 10⁵ and your plan compares every element with every other. Using Part 1's growth table: is the plan viable? What complexity should you announce as the target before designing?

Show Hint

Compute the operation count of "every element with every other" at n = 10⁵, then compare against ~10⁷ operations per second.

Show Approach

All pairs ≈ n²/2 = 5 × 10⁹ operations → hundreds of seconds. Over budget by ~500×. The budget therefore demands ≈ 10⁷ operations or fewer: O(n) or O(n log n).

Show Solution

Not viable. Target: O(n log n) or O(n). Say it out loud before designing — it instantly disqualifies whole families of ideas (nested loops, pairwise anything) and points you at structures that make lookups cheap: sets, dicts, sorting, windows.

2.3 Observation Practice

🟡 L2 · Apply

"Given a binary array, return the length of the longest run of consecutive 1s." Example: [1, 1, 0, 1, 1, 1] → 3. Write 2–3 concrete observations — structure, not plans.

Show Hint

What happens to a run when a 0 appears? What must you remember between two elements?

Show Approach

Runs are contiguous and reset at each 0. The run ending here can be extended by the next element, or destroyed by it. So two numbers suffice: current run length, best run length.

Show Solution

Observations: (1) a 0 resets the current run to zero — runs never survive gaps; (2) each 1 extends the run ending at that position by exactly 1 — old counts are reusable, nothing is re-scanned; (3) the answer is the maximum run seen, updatable in O(1). Pattern prediction: single pass with running state — O(n) time, O(1) space.

Challenge Problem

2.4 Full Reading Pass — "Best Time to Buy and Sell Stock"

🟠 L3 · Recognize
Problem

Given an array prices where prices[i] is a stock price on day i, choose one day to buy and a later day to sell, maximizing profit. Return the maximum profit; if no profit is possible, return 0. Your task is a complete reading pass — statement to prediction — with no solution code.

Input / Output

prices = [7, 1, 5, 3, 6, 4] → 5 (buy at 1 on day 1, sell at 6 on day 4)

Show Hint

Walk the seven questions. For question 6, watch what the brute force re-computes for each selling day. For question 7, look for a quantity that only ever needs to be "the best so far".

Show Approach

1) Given: one array of numbers, possibly large → assume n up to 10⁵. 2) Return: one number — maximum profit, floor 0. 3) Budget: n ≤ 10⁵ → O(n log n) or better. 4) Examples: profit = sell − buy with buy strictly earlier; negative-profit case returns 0. 5) Brute force: try every buy/sell pair — all pairs again, O(n²), dead at 10⁵. 6) Repeated work: for each selling day, the brute force re-searches the cheapest price before it — but the cheapest-so-far only changes when a new minimum appears. 7) Pattern: single pass keeping the running minimum and the best profit — the "running best" shape from Part 0's challenge, twice over.

Show Solution (the completed reading)
  • Pattern: single pass, two running values: min_price_so_far and best_profit.
  • Time: O(n) — each day inspected once. Space: O(1).
  • Feasibility: 10⁵ operations at n = 10⁵ — passes easily.
  • My own tests: strictly decreasing prices [9, 7, 4, 1] → 0 (no valid profit); single day [5] → 0; the answer buying at the true minimum but selling before it, e.g. [2, 4, 1, 7] → 5 (buy 1, sell 7, not the 2→4 pair).
Python
def max_profit(prices):
    min_so_far = prices[0]
    best = 0
    for p in prices[1:]:
        best = max(best, p - min_so_far)   # sell today?
        min_so_far = min(min_so_far, p)    # cheapest buy so far
    return best

The code is included only to confirm the prediction — the reading did all the real work.

Pattern Recognition
Pattern Insight

"Maximize a difference over an ordered sequence" + "the brute force re-finds a best-so-far" ⇒ single pass with running minimum/maximum. The tell is question 6: whenever a brute force re-computes a "best so far" that only changes rarely, remember it incrementally. Days, prices, temperatures, heights — the surface never matters; the shape always does.

Revision Checklist

PART 3

Arrays & Lists

The workhorse of DSA — and where most interviews begin.

Almost every problem you will ever solve stores its data in a sequence: scores, prices, log entries, pixels, characters. In Python that sequence is the list, and the mental model behind it is the array. Master a handful of list patterns — best-so-far, frequency counting, two pointers, running sums — and a huge fraction of interview problems collapses into "which pattern is this?"

This part is long on purpose. We will walk each core problem from brute force to the optimal beginner solution, with dry runs on real numbers, and end with the pitfalls that trip up nearly every newcomer.

The List as an Array — a row of numbered boxes

An array is a block of contiguous slots, each holding one value, each with a numbered index starting at 0. That single fact explains almost every performance property of lists:

420
71
192
33
254
5 slots, indices 0–4. The value at index i is one address-computation away.
Concept

Because slots sit side by side, Python can compute the address of nums[i] as start + i × slot size. No searching is needed — jumping to any index costs the same, whether the list has 10 elements or 10 million. That is what O(1) random access means.

The costs you should carry in your head:

OperationCodeCostWhy
Read / write by indexnums[i], nums[i] = vO(1)direct address computation
Append at the endnums.append(v)O(1) amortizedusually just drops into a spare slot
Insert / remove at the frontnums.insert(0, v), nums.pop(0)O(n)every later element shifts one slot
Search an unsorted listx in numsO(n)must look at elements one by one
Lengthlen(nums)O(1)stored, not counted
O(1) access
O(1) append
O(n) search
O(n) insert(0)
n = 100,000: accessing element 50,000 is instant; inserting at the front moves 100,000 elements.
Tip

When a solution needs insert(0, …) or pop(0) inside a loop, pause. You are paying O(n) per call. Almost always there is a way to work from the back (append / pop()) or to use two indices instead of shifting elements.

Indexing and Traversal — the two loops you will write forever

There are exactly three traversal styles worth knowing, and each fits a different situation:

Python
nums = [42, 7, 19, 3, 25]

# 1. By value — when you only need the elements
for x in nums:
    print(x)

# 2. By index — when you need positions, or want to WRITE nums[i]
for i in range(len(nums)):
    nums[i] = nums[i] * 2        # double every element in place

# 3. enumerate — value AND index together (preferred over range(len))
for i, x in enumerate(nums):
    print(i, x)

# Negative indices count from the end — no arithmetic needed
nums[-1]   # 25, the last element
nums[-2]   # 3,  the second-to-last
Q1. With nums = [10, 20, 30, 40], what does nums[-2] return?
Negative indices count from the end: -1 is 40, -2 is 30. No off-by-one arithmetic needed.
Common Mistake

range(len(nums)) produces 0 … len−1 — the last index is len(nums) - 1. Writing nums[len(nums)] raises IndexError. Whenever you touch nums[i + 1], loop only to len(nums) - 1.

Searching — checking slots one by one

The simplest search looks at every element until it finds the target. This is linear search, and its cost is O(n): on average you inspect half the list.

Python
def linear_search(nums, target):
    for i, x in enumerate(nums):
        if x == target:
            return i        # found: report the position
    return -1               # not found (sentinel value)
Dry Run — linear_search([42, 7, 19, 3, 25], 3)
i=0: x=42  → 42 == 3? ❌
i=1: x=7   → 7 == 3?  ❌
i=2: x=19  → 19 == 3? ❌
i=3: x=3   → 3 == 3?  ✅ return 3
Tip

Returning -1 for "not found" is a convention worth keeping: it can never be confused with a real index, and callers can test if idx == -1.

Linear search is the honest baseline for unsorted data. If the data is sorted, you can do dramatically better — that idea (binary search) gets its own part later in the book.


The "Best So Far" Pattern — one pass, one champion

Before the first full problem, internalize this: many array questions are won by carrying a single answer-so-far through one pass and challenging it at every element. Finding the maximum, the minimum, the best profit, the longest run — all are the same skeleton.

3.1 Find the Maximum

🟢 L1 · Understand
Problem

Given a non-empty list of numbers, return the largest value. (Python's built-in max() does this — but the point is to build the pattern by hand once.)

Input / Output

nums = [3, 9, 2, 9, 5] → 9

Key Observation

You do not need to compare every pair. If you know the largest of the first k elements, one comparison against element k+1 updates your knowledge. One pass suffices.

Brute Force

Compare every element with every other element, O(n²) — checking whether some value beats all others. It works, but each element gets re-examined many times.

Better Idea

Keep a champion best. Walk the list once; if the current element beats the champion, crown it. The repeated work that disappears: re-comparing already-seen elements against each other.

Optimal Beginner Solution
Python
def find_max(nums):
    best = nums[0]          # assume the first element is the champion
    for x in nums:
        if x > best:        # challenger wins
            best = x
    return best
Dry Run
Dry Run — find_max([3, 9, 2, 9, 5])
start : best = 3
x = 3 : 3 > 3?  ❌   best = 3
x = 9 : 9 > 3?  ✅   best = 9
x = 2 : 2 > 9?  ❌   best = 9
x = 9 : 9 > 9?  ❌   best = 9   (equal does not beat the champion)
x = 5 : 5 > 9?  ❌   best = 9
return 9
30
91best
22
93
54
the champion pointer only moves when a strictly bigger value appears
Complexity

Time O(n) — each element is challenged once · Space O(1) — one variable.

Edge Cases
  • All-negative list, e.g. [-8, -3, -5] → -3. This is why we start best = nums[0], not best = 0.
  • Single element → that element (the loop simply never updates).
  • Empty list → undefined; either raise an error or return None deliberately.
Common Mistakes
  • Initializing best = 0 — silently wrong for all-negative input.
  • Starting the loop at index 0 with best = nums[0] is fine, but if you start best at index 0 you may also begin the loop at index 1 — a micro-optimization, not a correctness issue.
  • Using >= instead of > — same answer here, but the habit breaks "first index of maximum" style variants.
Pattern Recognition
Pattern Insight

"Largest / smallest / best single value in one pass" ⇒ best-so-far with one O(1) challenger per element. Clues: the answer is a single element, order does not matter, and no element interacts with any other.

3.2 Second Largest (distinct values)

🟡 L2 · Apply
Problem

Return the second largest distinct value in the list. If it does not exist (fewer than two distinct values), return None.

Input / Output

nums = [3, 9, 2, 9, 5] → 5  ·  nums = [7, 7, 7] → None

Key Observation

Sorting is tempting but fragile: sorted([9, 9, 2])[-2] is 9, not 2 — duplicates betray you. What you really need is to track two champions: the best and the best among the rest.

Brute Force

Sort a copy and walk backwards from the end until the value differs from the maximum. O(n log n), plus a subtle loop most people get wrong on the first try.

Better Idea

Two passes: pass 1 finds the maximum; pass 2 finds the largest value strictly smaller than it. O(n) time, and the "distinct" requirement is handled by x != best.

Optimal Beginner Solution (one pass, two variables)
Python
def second_largest(nums):
    best = None
    second = None
    for x in nums:
        if best is None or x > best:
            second = best        # old champion is demoted to runner-up
            best = x
        elif x != best and (second is None or x > second):
            second = x           # new runner-up
    return second
Better Idea in Code (two passes — easier to reason about)
Python
def second_largest_two_pass(nums):
    best = nums[0]
    for x in nums:
        if x > best:
            best = x
    second = None
    for x in nums:
        if x != best and (second is None or x > second):
            second = x
    return second
Dry Run
Dry Run — second_largest([3, 9, 2, 9, 5])
x = 3 : best is None      → second = None, best = 3
x = 9 : 9 > 3             → second = 3,    best = 9
x = 2 : 2 != 9, 2 > 3?    ❌  (no change)
x = 9 : 9 > 9? ❌; 9 != 9? ❌  (duplicate champion ignored)
x = 5 : 5 != 9, 5 > 3     → second = 5
return 5  ✅
Complexity

One-pass version: Time O(n) · Space O(1). Two-pass: O(2n) = O(n) · O(1).

Edge Cases
  • [7, 7, 7] → None (only one distinct value).
  • Two elements [1, 2] → 1.
  • Negatives [-5, -2, -9] → -5; using None sentinels (not 0!) is what makes this safe.
Common Mistakes
  • Initializing second = 0 or second = -1 — breaks on negative input.
  • Forgetting the x != best guard, so duplicates of the maximum become the runner-up.
  • Updating second before best when a new champion arrives — you lose the old champion.
Pattern Recognition
Pattern Insight

Whenever one best-so-far variable is not enough — top 2, top k, max and second max — the pattern extends naturally: keep a small, ordered set of champions and give each challenger a fair trial against all of them.

Frequency Counting — remember what you have seen

A huge family of problems ("how many", "which one repeats", "do these two match") is solved by a dictionary that maps each value to how often it appears. Building the table is one O(n) pass; afterwards every query about a value is O(1).

Python
def frequency_table(nums):
    counts = {}
    for x in nums:
        counts[x] = counts.get(x, 0) + 1
    return counts

frequency_table([4, 2, 4, 7, 2, 9])
# → {4: 2, 2: 2, 7: 1, 9: 1}
Concept

counts.get(x, 0) means "current count of x, or 0 if x is new" — it replaces the awkward "check membership, then add" dance. The trade you are making: spend O(n) memory once, buy O(1) lookups forever. This is the single most profitable trade in beginner DSA.


Copy vs Modify — who owns the list?

Two assignment statements that look identical behave completely differently:

Python
a = [1, 2, 3]
b = a            # ALIAS: b and a are two names for ONE list
b[0] = 99
print(a)         # [99, 2, 3]  ❌ surprise!

c = [1, 2, 3]
d = c[:]         # COPY: a brand-new list (also c.copy() or list(c))
d[0] = 99
print(c)         # [1, 2, 3]  ✅ original untouched
Important — nums[:] vs nums
  • b = nums copies the reference, not the list. Both names now point at the same boxes: any write through either name is visible through both.
  • b = nums[:] (slice from start to end) builds a shallow copy — a new list with the same values.
  • Problems that say "modify in place" forbid the copy: they want O(1) extra space and they inspect the original list afterwards.
  • Problems that say "return the array…" usually allow building a new one. When in doubt, re-read the constraints.
ExpressionWhat you getExtra spaceOriginal safe?
b = numsalias — same listO(1)❌ writes leak through
b = nums[:]shallow copyO(n)✅
b = nums[::-1]reversed copyO(n)✅
nums.reverse()in-place reversal (returns None!)O(1)❌ mutates

In-Place Rewriting — the insert_pos trick

Several classic problems ask you to compact an array in place: keep the elements you want at the front, overwrite the rest. The tool is a write pointer that trails behind the read pointer. The next three problems all use some flavor of it.

10write
01
02read
33
124
read scans every element; write marks where the next keeper belongs

3.3 Remove Duplicates (keep first occurrences, in order)

🟡 L2 · Apply
Problem

Given a list, return a new list containing each distinct value once, in the order the value first appeared.

Input / Output

nums = [4, 2, 4, 7, 2, 9] → [4, 2, 7, 9]

Key Observation

For each element the question is "have I seen this before?" — a membership test. A list membership test is O(n); a set membership test is O(1). Same logic, radically different total cost.

Brute Force

Append x only if x not in result. Correct, but in on a list scans it, giving O(n²) overall — fine for 1,000 elements, painful for 1,000,000.

Better Idea / Optimal

Keep a seen set alongside the result list. The repeated work that disappears: scanning the output to test membership.

Python Solution
Python
def remove_duplicates(nums):
    seen = set()
    result = []
    for x in nums:
        if x not in seen:      # first time we meet x
            seen.add(x)
            result.append(x)
    return result

# Brute force for contrast — same output, O(n^2):
# if x not in result: result.append(x)
Dry Run
Dry Run — remove_duplicates([4, 2, 4, 7, 2, 9])
x = 4 : 4 in seen? ❌ → seen={4}      result=[4]
x = 2 : 2 in seen? ❌ → seen={2, 4}   result=[4, 2]
x = 4 : 4 in seen? ✅ skip
x = 7 : 7 in seen? ❌ → seen={2, 4, 7} result=[4, 2, 7]
x = 2 : 2 in seen? ✅ skip
x = 9 : 9 in seen? ❌ → seen={2, 4, 7, 9} result=[4, 2, 7, 9]
Complexity

Time O(n) · Space O(n) for the set and result.

Edge Cases
  • Empty list → [].
  • All duplicates [5, 5, 5] → [5].
  • Already unique → returned unchanged, in the same order.
Common Mistakes
  • Using list(set(nums)) — it destroys the required first-occurrence order.
  • Mutating nums while looping over it (see Pitfalls below) instead of building a separate result.
Pattern Recognition
Pattern Insight

"Seen before?" questions ⇒ a set built during one pass. If the statement additionally says the input is sorted, duplicates sit next to each other and you can even skip the set — compare each element with the previous one.

3.4 Move Zeroes to the End (in place)

🟡 L2 · Apply
Problem

Move all 0s to the end of the list while keeping the relative order of the non-zero elements. Modify the list in place; do not return a new list.

Input / Output

nums = [0, 1, 0, 3, 12] → [1, 3, 12, 0, 0]

Key Observation

Non-zero elements keep their order — so imagine writing the keepers to the front one by one, then padding the tail with zeros. A write pointer (left) marks the next free front slot; a read pointer (right) scans every element.

Brute Force

Build [x for x in nums if x != 0], then append zeros to match the original length and copy back. O(n) time but O(n) extra space — the statement demands in-place, so this only qualifies as a stepping stone.

Better Idea (fill, then zero the tail)
Python
def move_zeroes_fill(nums):
    insert_pos = 0
    for x in nums:
        if x != 0:
            nums[insert_pos] = x   # write the keeper forward
            insert_pos += 1
    for i in range(insert_pos, len(nums)):
        nums[i] = 0                # pad the tail with zeroes
Optimal Beginner Solution (one pass, swap)

The fill version makes two passes. A single pass can swap each keeper directly into its final slot — after every swap, everything before left is exactly the non-zero elements seen so far, in order.

Python
def move_zeroes(nums):
    left = 0                        # next slot for a non-zero
    for right in range(len(nums)):
        if nums[right] != 0:
            nums[left], nums[right] = nums[right], nums[left]
            left += 1
Dry Run
Dry Run — move_zeroes([0, 1, 0, 3, 12])
right=0: nums[0]=0  → skip            [0, 1, 0, 3, 12]  left=0
right=1: nums[1]=1  → swap 0,1        [1, 0, 0, 3, 12]  left=1
right=2: nums[2]=0  → skip            [1, 0, 0, 3, 12]  left=1
right=3: nums[3]=3  → swap 0,3        [1, 3, 0, 0, 12]  left=2
right=4: nums[4]=12 → swap 0,12       [1, 3, 12, 0, 0]  left=3
✅ done — non-zero order preserved (1, 3, 12)
10
31
02left
03
124right
last swap: right finds 12, left is waiting at index 2 → [1, 3, 12, 0, 0]
Complexity

Time O(n) — each element visited once · Space O(1) — true in-place.

Edge Cases
  • No zeros → the swaps are self-swaps; the list is unchanged.
  • All zeros → left never moves; nothing to do.
  • Zeros already at the end → also unchanged.
Common Mistakes
  • Swapping when nums[right] == 0 — you would shuffle zeros into the front.
  • Forgetting left += 1 after a swap, overwriting the same slot forever.
  • Using nums.remove(0) in a loop: O(n) per call and it mutates the list you are iterating.
Pattern Recognition
Pattern Insight

"Partition the array in place — keepers left, discards right, order preserved" ⇒ two pointers moving in the same direction with a write pointer. The invariant to say out loud: everything before left is already a keeper.

3.5 Missing Number (0 to n, one missing)

🟡 L2 · Apply
Problem

A list holds n distinct numbers taken from 0, 1, …, n. Exactly one of them is missing. Find it.

Input / Output

nums = [3, 0, 1] → 2  ·  nums = [9, 6, 4, 2, 3, 5, 7, 0, 1] → 8

Key Observation

The list is almost the full set 0…n. The sum of the full set is n·(n+1)/2 (Gauss's formula). Whatever the sum of the list falls short by is exactly the missing value. No searching, no sorting — arithmetic.

Brute Force

For each candidate v in 0…n, check v not in nums. Correct, but every membership check is O(n) — O(n²) total.

Better Idea

Sort a copy, then report the first index i where s[i] != i. O(n log n) — sorting buys order but costs more than we need.

Optimal Beginner Solution
Python
def missing_number(nums):
    n = len(nums)                    # values run from 0 to n
    expected = n * (n + 1) // 2      # 0 + 1 + ... + n
    actual = sum(nums)
    return expected - actual

# Sorting version for contrast:
# s = sorted(nums)
# for i in range(len(s)):
#     if s[i] != i:
#         return i
# return len(s)
Dry Run
Dry Run — missing_number([9, 6, 4, 2, 3, 5, 7, 0, 1])
n        = 9            (so values should span 0..9)
expected = 9·10/2 = 45
actual   = 9+6+4+2+3+5+7+0+1 = 37
missing  = 45 - 37 = 8  ✅
Complexity

Time O(n) — one sum() pass · Space O(1).

Edge Cases
  • Missing value is 0: nums = [1] → n = 1, expected = 1·2/2 = 1, sum(nums) = 1 → 1 − 1 = 0 ✅.
  • Missing value is n itself: [0, 1] → expected 3, actual 1 → 2 ✅.
  • The repeated work that disappears versus brute force: every per-candidate membership scan — replaced by one subtraction.
Common Mistakes
  • Using n(n+1)/2 with / instead of // — you get a float like 2.0, and the return type is wrong.
  • Setting n = len(nums) - 1: the list has n elements but the range spans n+1 values (one is missing!).
  • Integer overflow — a real concern in C/Java, a non-issue in Python (arbitrary precision integers). Know why you get away with it here.
Pattern Recognition
Pattern Insight

"Values come from a known complete range, exactly one is missing" ⇒ compare against the expected total (sum). The same trick works with XOR: xoring all values with all indices leaves the missing number, with zero overflow risk in any language.

Q2. In the best-so-far pattern, why initialize best = nums[0] instead of best = 0?
With best = 0, input [-8, -3, -5] would wrongly return 0 — no element ever "beats" it. Starting from a real element makes the code correct for all-negative input.

3.6 Rotate Array by k Steps

🟠 L3 · Recognize
Problem

Rotate the list to the right by k steps, in place: each element moves k slots forward, and elements that fall off the end re-enter at the front.

Input / Output

nums = [1, 2, 3, 4, 5, 6, 7], k = 3 → [5, 6, 7, 1, 2, 3, 4]

Key Observation

Rotating right by k means: the last k elements jump to the front. Look at the answer in two blocks: [5, 6, 7] + [1, 2, 3, 4]. Both blocks are in their original internal order — they were just cut and swapped. And a cut-and-swap is exactly what three reversals produce.

Brute Force

Rotate one step at a time, k times: pop the last element and insert it at the front. Each step costs O(n) because insert(0, …) shifts everything — O(n·k) total. For n = 100,000 and k = 99,999 that is about ten billion moves.

Python
def rotate_naive(nums, k):
    n = len(nums)
    if n == 0:
        return nums
    k = k % n                     # k full cycles change nothing
    for _ in range(k):
        last = nums.pop()         # O(1)  — take from the end
        nums.insert(0, last)      # O(n)! — shift the whole list
    return nums

The repeated work: every element near the front gets shifted once per rotation step. k steps → k full shifts.

Python
def rotate_extra(nums, k):
    n = len(nums)
    if n == 0:
        return nums
    k = k % n
    nums[:] = nums[-k:] + nums[:-k]   # last k block + first block
    return nums

O(n) time — but O(n) extra space for the temporary concatenation, and the statement usually says "in place, O(1) extra space". Note the slice subtlety: when k % n == 0, nums[-0:] is the whole list and nums[:-0] is empty, so the assignment is harmless — but only because we reduced k first.

Python
def rotate(nums, k):
    n = len(nums)
    if n == 0:
        return nums
    k = k % n

    def reverse(lo, hi):
        while lo < hi:
            nums[lo], nums[hi] = nums[hi], nums[lo]
            lo += 1
            hi -= 1

    reverse(0, n - 1)     # step 1: flip everything
    reverse(0, k - 1)     # step 2: flip the first k back
    reverse(k, n - 1)     # step 3: flip the rest back
    return nums

O(n) time (each element swapped at most twice), O(1) extra space. This is the interview favorite.

The Reversal Trick, step by step
10
21
32
43
54
65
76
start: [1, 2, 3, 4, 5, 6, 7], k = 3
70L
61
52
43
34
25
16R
after reverse(0, 6): the whole array is flipped — the last k elements now LEAD, but internally reversed
50L
61
72R
43
34
25
16
after reverse(0, 2): the first block [5, 6, 7] is back in order
50
61
72
13L
24
35
46R
after reverse(3, 6): [5, 6, 7, 1, 2, 3, 4] ✅ — rotated by k, in place
Dry Run
Dry Run — rotate([1, 2, 3, 4, 5, 6, 7], k = 3)
k = 3 % 7 = 3
reverse(0, 6):  [7, 6, 5, 4, 3, 2, 1]
reverse(0, 2):  [5, 6, 7, 4, 3, 2, 1]
reverse(3, 6):  [5, 6, 7, 1, 2, 3, 4]  ✅
Complexity

Reversal trick: Time O(n) · Space O(1). Extra-array: Time O(n) · Space O(n). Naive: Time O(n·k) · Space O(1).

Edge Cases
  • k > n: rotating 7 slots on a 7-element list restores the original — so always k = k % n first. k = 10 on the array above behaves exactly like k = 3.
  • k = 0 or k % n == 0 → array unchanged: the middle call reverse(0, k - 1) becomes reverse(0, -1), a no-op when lo > hi, and the first and last calls each flip the whole array — two full reversals cancel out.
  • Single element → any k leaves it as is.
Common Mistakes
  • Forgetting k %= n — wasted work at best, and index trouble in some variants.
  • Reversing segments in the wrong order (first-k then tail then whole — gives a different, wrong result).
  • Confusing "rotate right" with "rotate left": left by k is the mirror image — reverse the first n−k, then the last k, then the whole.
Pattern Recognition
Pattern Insight

"Move a block from one end to the other, in place" ⇒ think reverse segments. A reversal is the only O(1)-space primitive that reorders a block, and reversing a reversal restores internal order — which is why the trick composes so cleanly.

Q3. Before rotating, why compute k = k % n?
k = 10 on a length-7 array equals k = 3. Reducing first avoids pointless passes (and, in slice-based code, avoids index bugs with k = 0).

3.7 Best Time to Buy and Sell Stock (one transaction)

🟠 L3 · Recognize
Problem

prices[i] is a stock's price on day i. Choose one day to buy and one later day to sell. Return the maximum profit; if no profit is possible, return 0.

Input / Output

prices = [7, 1, 5, 3, 6, 4] → 5 (buy at 1, sell at 6)  ·  prices = [7, 6, 4, 3, 1] → 0

Key Observation

The best profit selling on day i is prices[i] − (cheapest price before day i). So if you always know the minimum price seen so far, each day needs only one comparison. That "so far" quantity is the running-minimum idea from the prefix/suffix toolbox.

Brute Force

Try every buy day i with every sell day j > i and keep the best difference — every pair, O(n²). What repeats: for each buy day, we rescan the whole future to find its best sell day.

Better Idea

Flip the question: for each sell day, what is the best buy day? It is always "the cheapest day before it". Maintain min_so_far in one pass — the rescan of the past disappears.

Optimal Beginner Solution
Python
def max_profit(prices):
    min_so_far = prices[0]   # cheapest price seen up to today
    best = 0                 # best profit achievable so far
    for price in prices:
        if price < min_so_far:
            min_so_far = price            # a cheaper buy day appeared
        elif price - min_so_far > best:
            best = price - min_so_far     # selling today beats the record
    return best
Dry Run
Dry Run — max_profit([7, 1, 5, 3, 6, 4])
start:     min_so_far = 7, best = 0
price = 7: 7 < 7? ❌ → profit 7-7 = 0;  best = 0
price = 1: 1 < 7? ✅ → min_so_far = 1
price = 5: 5 < 1? ❌ → profit 5-1 = 4 → best = 4
price = 3: 3 < 1? ❌ → profit 3-1 = 2;  best stays 4
price = 6: 6 < 1? ❌ → profit 6-1 = 5 → best = 5
price = 4: 4 < 1? ❌ → profit 4-1 = 3;  best stays 5
return 5  ✅  (buy day index 1 at 1, sell day index 4 at 6)
70
11buy
52
33
64sell
45
best window: buy 1 → sell 6, profit 5
Complexity

Time O(n) — one pass · Space O(1) — two variables. The O(n²) pair loop is reduced to O(n) because the history of prices collapses into one number: its minimum.

Edge Cases
  • Strictly falling prices [7, 6, 4, 3, 1] → 0 (never a negative profit — we simply never sell).
  • Single price → 0 (you cannot buy and sell on the same day for profit).
  • Best buy is the last day, best sell the first — impossible ordering; the code never sells before buying because min_so_far only knows the past.
Common Mistakes
  • Updating min_so_far after computing the profit for the same day — then you could "buy and sell on the same day". Here the if/elif order makes buying on day i and selling on day i mutually exclusive, which is what we want.
  • Initializing best = -1 or min_so_far = 0.
  • Trying to track both the min and its index when the problem only asks for the profit.
Pattern Recognition
Pattern Insight

"Best pair (i, j) with i before j, scored by a difference" ⇒ sweep left to right keeping the best history summary (here: min). If the statement instead wants the days, remember the min's index too. Multiple transactions allowed? Then it decomposes into every rising run — a different (and easier) problem.


Prefix and Suffix Ideas — summarize everything on one side

Min-so-far, running totals, "largest to my right" — all are the same idea: as you walk the array, maintain a summary of one side (everything before you, or everything after you). It costs O(n) once and turns many "compare with all others" questions into O(1) lookups.

Python
def running_sum(nums):
    prefix = []
    total = 0
    for x in nums:
        total += x
        prefix.append(total)     # prefix[i] = nums[0] + ... + nums[i]
    return prefix

running_sum([2, 4, 1, 5])   # [2, 6, 7, 12]

def prefix_max(nums):
    best = nums[0]
    result = []
    for x in nums:
        best = max(best, x)      # largest value seen so far
        result.append(best)
    return result

prefix_max([3, 1, 4, 1, 5])   # [3, 3, 4, 4, 5]
Summary you keepUpdated byUsed in
running total (prefix sum)total += xsubarray sums, range sum queries
min so farmin_so_far = min(min_so_far, x)buy/sell stock
max so far (prefix max)best = max(best, x)find maximum, prefix max tables
max to the right (suffix max)scan right → leftleaders (Drill 3.6)
sum ending herecurrent = max(x, current + x)Kadane, next section
Concept

The magic move is collapsing history into one number. "Everything before index i" is n possible prefixes — but for many questions you only need one statistic about them (sum, min, max). Keep that statistic updated as you go and the past costs you O(1) per step.


Subarrays — windows of consecutive elements

A subarray is a contiguous slice: nums[i..j]. It is not the same as a subsequence (which may skip elements) or a subset. For an array of length n there are n·(n+1)/2 subarrays — for [1, 2, 3]:

All subarrays of [1, 2, 3]
start at 0:  [1]   [1, 2]   [1, 2, 3]
start at 1:  [2]   [2, 3]
start at 2:  [3]
total = 3 + 2 + 1 = 6 = n(n+1)/2

Counting is one thing; scoring them all is another. "Maximum subarray sum" is the canonical example — and the gentlest possible introduction to recognizing when a running summary replaces a double loop.

3.8 Maximum Subarray Sum (Kadane's Algorithm)

🟠 L3 · Recognize
Problem

Find the contiguous subarray with the largest sum and return that sum (the subarray must contain at least one element).

Input / Output

nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4] → 6 (the window [4, -1, 2, 1])

Key Observation

Every subarray ends somewhere. So the answer is the best of the question "what is the best subarray that ends exactly here?" asked at each position — n sub-questions instead of n² windows. And each sub-question is answerable from the previous one in O(1).

Brute Force

Score every window: for each start i, extend the end j while accumulating the total. O(n²) with a running total (O(n³) if you re-sum each window from scratch).

Better Idea → Optimal: Kadane's Algorithm

Keep current = the best sum of a subarray that ends at the element we are standing on. When the next element x arrives there are only two choices, and one of them is always best:

  • Extend the previous run: current + x — worth it when the previous run helps, i.e. current is positive enough.
  • Restart here: just x — better when the previous run is a liability (its sum is so low that carrying it hurts).

So: current = max(x, current + x), and the global answer best = max(best, current). The repeated work that disappears: re-summing overlapping windows — each ending position keeps exactly one number.

Python Solution
Python
def max_subarray_sum(nums):
    current = nums[0]   # best subarray sum ENDING at the current element
    best = nums[0]      # best ending anywhere so far
    for i in range(1, len(nums)):
        current = max(nums[i], current + nums[i])  # restart, or extend
        best = max(best, current)
    return best

# Brute force for contrast — O(n^2):
# best = nums[0]
# for i in range(len(nums)):
#     total = 0
#     for j in range(i, len(nums)):
#         total += nums[j]          # window nums[i..j]
#         best = max(best, total)
Dry Run — the "running best ending here" row
Dry Run — max_subarray_sum([-2, 1, -3, 4, -1, 2, 1, -5, 4])
start:         current = -2, best = -2
i=1, x =  1 :  current = max( 1, -2+1) = max( 1, -1) =  1  → restart   best = 1
i=2, x = -3 :  current = max(-3,  1-3) = max(-3, -2) = -2  → extend    best = 1
i=3, x =  4 :  current = max( 4, -2+4) = max( 4,  2) =  4  → restart   best = 4
i=4, x = -1 :  current = max(-1,  4-1) = max(-1,  3) =  3  → extend    best = 4
i=5, x =  2 :  current = max( 2,  3+2) = max( 2,  5) =  5  → extend    best = 5
i=6, x =  1 :  current = max( 1,  5+1) = max( 1,  6) =  6  → extend    best = 6
i=7, x = -5 :  current = max(-5,  6-5) = max(-5,  1) =  1  → extend    best = 6
i=8, x =  4 :  current = max( 4,  1+4) = max( 4,  5) =  5  → extend    best = 6
return 6  ✅
The whole run at a glance
index      0     1     2     3     4     5     6     7     8
value     -2     1    -3     4    -1     2     1    -5     4
end-here  -2     1    -2     4     3     5     6     1     5
best      -2     1     1     4     4     5     6     6     6

Read the end-here row left to right: it only "accepts" the past when the past is not a burden. At index 2 the run is dragging (-2), so index 3 drops it and restarts at 4.

-20
11
-32
43L
-14
25
16R
-57
48
the winning window [4, -1, 2, 1] → sum 6; it was restarted once, then extended four times
Complexity

Time O(n) — one pass, O(1) work per element · Space O(1) — two variables. The O(n²) brute force collapses because each ending position compresses all windows ending there into a single number.

Edge Cases
  • All negative [-3, -1, -7] → -1: the best "window" is the single least-negative element. This is why both current and best start at nums[0] — never 0.
  • Single element → that element.
  • All positive → the whole array (extending always wins).
Common Mistakes
  • Initializing best = 0 — returns 0 on all-negative input instead of the best single element.
  • Swapping the two maxes: current = max(current, best + x) mixes up the two meanings and silently produces wrong answers.
  • Allowing an empty window when the problem demands at least one element.
Pattern Recognition
Pattern Insight

"Best contiguous window sum/value over an array" ⇒ Kadane thinking: define the best value ending at each position, derive it from the previous position in O(1), track the global best. Clues: contiguous, one window, additive score, and "at least one element".

Q4. In Kadane's algorithm, what exactly does the variable current mean after processing element i?
That's the whole trick: current answers a narrow question ("best window ending HERE"), and best = max(best, current) widens it to the global answer. Confusing the two is the classic Kadane bug.

In-Place Operations — swap, overwrite, shift

Three in-place tools cover most array surgery:

  • Swap: Python's tuple assignment nums[a], nums[b] = nums[b], nums[a] — no temporary variable needed. Used by reversal, rotation, and partitioning.
  • Overwrite via write pointer: compact keepers to the front (insert_pos pattern from Move Zeroes), then handle the tail.
  • Built-ins that mutate: nums.sort(), nums.reverse(), nums.append(x), nums.pop() change the list and return None (except pop). sorted(nums) and nums[::-1] are their non-mutating twins.
Python
nums = [3, 1, 2]

other = nums.sort()      # ❌ other is None — sort() mutates and returns nothing
nums.sort()              # ✅ call for the side effect, on its own line

copy = sorted(nums)      # ✅ new sorted list, nums untouched
rev  = nums[::-1]        # ✅ new reversed list
Tip

Choosing between in-place and copying is an engineering decision: in-place saves memory but destroys the input; copying costs O(n) space but keeps the original. Read the problem's constraints — "O(1) extra space" or "modify in place" decides it for you.


Common Pitfalls — the four bugs everyone writes once

Pitfall 1 — Aliasing: two names, one list

b = a does not copy. Both names are handles on the same boxes, so "modifying the copy" corrupts the original — and passing a list to a function passes the same handle.

Q5. a = [1, 2, 3], then b = a, then b[0] = 99. What is a[0]?
b is an alias of a — one list, two names. To get an independent list use a[:], a.copy(), or list(a).

Pitfall 2 — Off-by-one at the edges

The last valid index is len(nums) - 1. Two recurring shapes:

Python
nums[len(nums)]                 # ❌ IndexError — always

# comparing neighbors: stop one early
for i in range(len(nums) - 1):  # ✅ i+1 stays in range
    if nums[i] > nums[i + 1]:
        ...

Pitfall 3 — Mutating a list while iterating it

Python's for loop walks a list by position. Remove an element and everything after it slides left — the loop then skips the element that moved into the slot it just examined.

Python
nums = [1, 2, 2, 3]
for x in nums:            # ❌ modifying what you are walking
    if x == 2:
        nums.remove(x)
# walk: pos 0 → 1 (keep); pos 1 → 2, remove → [1, 2, 3];
# pos 2 → 3 (keep). The SECOND 2 slid into pos 1 and was skipped!
# result: [1, 2, 3]  ❌ a 2 survived

nums = [x for x in nums if x != 2]   # ✅ build a new list
for x in nums[:]:                    # ✅ or iterate over a copy
    if x == 2:
        nums.remove(x)

Pitfall 4 — Methods that return None

sort(), reverse(), and append() mutate in place and return None. Chaining them (nums = nums.sort()) silently replaces your list with None. Call them on their own line.

Common Mistake

All four pitfalls share a root cause: believing the variable holds the data itself. In Python the variable holds a reference — the data lives in one place, and many names (and the loop, and the function parameter) can point at it.


Quick Drills — one pattern each, under a minute

Drill 3.1 — Count Occurrences

🟢 L1

Count how many times target appears in nums. Example: [1, 2, 2, 3, 2], target 2 → 3.

Show Hint

A counter that wakes up on every match — the counting cousin of best-so-far.

Show Solution
Python
def count_occurrences(nums, target):
    count = 0
    for x in nums:
        if x == target:
            count += 1
    return count

# the built-in way: nums.count(target)

Time O(n) · Space O(1).

Drill 3.2 — Reverse an Array (in place)

🟢 L1

Reverse nums without extra space. Example: [1, 2, 3, 4, 5] → [5, 4, 3, 2, 1].

Show Hint

Two pointers at the ends, swap, walk inward — stop when they meet or cross.

Show Solution
Python
def reverse_array(nums):
    left, right = 0, len(nums) - 1
    while left < right:
        nums[left], nums[right] = nums[right], nums[left]
        left += 1
        right -= 1
    return nums

# non-mutating twin: nums[::-1]

Time O(n) (n/2 swaps) · Space O(1). This same loop powers the rotate trick (3.6).

Drill 3.3 — Find Common Elements

🟢 L1

Return the values present in both lists, without duplicates. Example: [1, 2, 2, 3] and [2, 3, 4] → [2, 3].

Show Hint

"Present in the other list?" is a membership test — make it O(1) with a set.

Show Solution
Python
def common_elements(a, b):
    pool = set(b)              # O(1) membership tests
    result = []
    for x in a:
        if x in pool:
            result.append(x)
            pool.discard(x)    # also deduplicates the output
    return result

Time O(n + m) · Space O(m). (set(a) & set(b) also works but loses first-occurrence order.)

Drill 3.4 — Merge Two Sorted Arrays

🟡 L2

Merge two ascending lists into one ascending list. Example: [1, 3, 5] and [2, 4, 6] → [1, 2, 3, 4, 5, 6].

Show Hint

One pointer per list. At each step, advance the pointer holding the smaller value — you never need to look backwards.

Show Solution
Python
def merge_sorted(a, b):
    i = j = 0
    merged = []
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            merged.append(a[i])
            i += 1
        else:
            merged.append(b[j])
            j += 1
    merged.extend(a[i:])     # one of these is empty
    merged.extend(b[j:])
    return merged

Time O(n + m) — each element appended once · Space O(n + m). This is the merge step of merge sort, met again later in the book.

Drill 3.5 — Check if an Array is Sorted

🟢 L1

Return True if every element is ≤ the next one. Example: [1, 2, 2, 3] → True; [3, 1, 2] → False.

Show Hint

The array is unsorted the moment one neighbor pair is out of order — so you can bail out early.

Show Solution
Python
def is_sorted(nums):
    for i in range(len(nums) - 1):   # note: -1, so i+1 is safe
        if nums[i] > nums[i + 1]:
            return False
    return True

Time O(n) worst case, often better with an early exit · Space O(1).

Drill 3.6 — Find the Leaders

🟡 L2

An element is a leader if it is strictly greater than every element to its right. The last element is always a leader. Example: [16, 17, 4, 3, 5, 2] → [17, 5, 2].

Show Hint

"Greater than everything to my right" is a suffix question. Walk from the right keeping the max seen — that is suffix-max on the fly.

Show Solution
Python
def find_leaders(nums):
    n = len(nums)
    leaders = [nums[n - 1]]            # last element is always a leader
    max_to_the_right = nums[n - 1]
    for i in range(n - 2, -1, -1):     # walk right to left
        if nums[i] > max_to_the_right:
            leaders.append(nums[i])
            max_to_the_right = nums[i]
    leaders.reverse()                  # restore left-to-right order
    return leaders

Dry run on [16, 17, 4, 3, 5, 2]: 2 → leader (max 2); 5 > 2 → leader (max 5); 3 ❌; 4 ❌; 17 > 5 → leader (max 17); 16 ❌. Collected [2, 5, 17], reversed → [17, 5, 2] ✅. Time O(n) · Space O(1) beyond the output.

What You Learned

  • A list is a row of indexed boxes: O(1) access by index, O(n) insert/remove at the front, O(n) search on unsorted data.
  • The best-so-far pattern: one pass, one champion, one challenger per element — maximum, second largest, minimum all reuse it.
  • Frequency counting: trade O(n) memory for O(1) "have I seen x?" lookups with a dictionary or set.
  • In-place rewriting: a write pointer compacts keepers to the front; swaps reorder without extra space.
  • Rotation by k is three reversals; k %= n first.
  • Running summaries (min-so-far, running sum, best-ending-here) collapse O(n²) pair/window scans into O(n) passes.
  • Copy (nums[:]) versus alias (nums) — and when each is correct.

Pattern Recognition

Map the problem's wording to the pattern: "largest/smallest single value" ⇒ best-so-far; "seen before / duplicate / how many" ⇒ set or frequency dictionary; "partition in place, keep order" ⇒ write pointer trailing a read pointer; "values form a complete range, one missing" ⇒ compare with the expected sum; "best contiguous window" ⇒ Kadane's best-ending-here; "best pair i before j" ⇒ sweep with a history summary such as min-so-far. Before writing any loop, ask what statistic about the past would make the next step O(1)?

Complexity Summary

Operation / ProblemTimeSpace
Access / write by indexO(1)O(1)
Linear search, find max, is sortedO(n)O(1)
Frequency table / dedupe with a setO(n)O(n)
Second largest (one pass)O(n)O(1)
Move zeroes (write pointer / swap)O(n)O(1)
Missing number (sum formula)O(n)O(1)
Rotate array (three reversals)O(n)O(1)
Best buy/sell (min-so-far)O(n)O(1)
Kadane's max subarrayO(n)O(1)
Merge two sorted arraysO(n + m)O(n + m)

Common Mistakes

  • b = a then mutating b, expecting a to be safe (aliasing).
  • best = 0 instead of best = nums[0] — breaks on all-negative input.
  • Looping to len(nums) when the body touches nums[i + 1].
  • Removing from a list while iterating it — elements get skipped.
  • nums = nums.sort() — assigns None.
  • insert(0, …) or pop(0) inside a loop — hidden O(n) per call.
  • Rotating without k %= n.

Mini Quiz

MQ1. What is the cost of reading nums[i]?
Contiguous slots mean the address of any index is computed directly — no scanning.
MQ2. What does b = nums[:] create?
Slicing from start to end builds a new list with the same values — writes to b no longer touch nums.
MQ3. In the move-zeroes two-pointer solution, what is true about elements before index left?
That invariant is why the algorithm is correct: left only advances after a keeper is placed, and right never reorders keepers.
MQ4. The sum-formula solution for the missing number runs in…
One sum() pass, one subtraction, two scalar variables. The sorting alternative pays O(n log n); brute force pays O(n²).
MQ5. Which loop is buggy for deleting all 2s from nums?
Iterating the list you mutate skips elements that slide into visited positions. The other two either build a new list or walk over a snapshot copy.

Practice Problems

Practice 3.1 — Contains Duplicate

🟢 L1

Return True if any value appears at least twice. Example: [1, 4, 2, 4] → True; [1, 2, 3] → False.

Show Hint

Sorting works (neighbors equal?), but a set answers "have I seen this?" in O(1) — and you can stop at the first repeat.

Show Approach

Walk once, keep a seen-set; on the first element already present, return True immediately. The repeated work that disappears versus comparing all pairs: every O(n²) pair check.

Show Solution
Python
def contains_duplicate(nums):
    seen = set()
    for x in nums:
        if x in seen:
            return True
        seen.add(x)
    return False

Time O(n) · Space O(n).

Practice 3.2 — Remove Element (in place)

🟡 L2

Remove all occurrences of val in place; return the new length k with the first k slots holding the keepers. Example: [3, 2, 2, 3], val 3 → k = 2, front is [2, 2, …].

Show Hint

This is Move Zeroes in disguise — the write-pointer pattern does not care what the "discard" value is.

Show Approach

One read pass; every element different from val is written to slot k and k advances. No second pass is needed because the tail beyond k may hold anything.

Show Solution
Python
def remove_element(nums, val):
    k = 0
    for x in nums:
        if x != val:
            nums[k] = x
            k += 1
    return k

Time O(n) · Space O(1).

Practice 3.3 — Max Consecutive Ones

🟢 L1

Binary list; return the longest run of consecutive 1s. Example: [1, 1, 0, 1, 1, 1] → 3.

Show Hint

Two counters: the current run and the best run. What event ends a run?

Show Approach

Increment run on a 1, reset it to 0 on anything else, and record best = max(best, run) while growing. Kadane's idea with a binary alphabet.

Show Solution
Python
def max_consecutive_ones(nums):
    best = 0
    run = 0
    for x in nums:
        if x == 1:
            run += 1
            best = max(best, run)
        else:
            run = 0
    return best

Time O(n) · Space O(1).

Challenge Problem

Challenge 3 — Product of Array Except Self (no division)

🟠 L3 · Recognize
Problem

Return a list answer where answer[i] is the product of every element except nums[i]. Division is off the table, and the solution must run in O(n). Example: [1, 2, 3, 4] → [24, 12, 8, 6].

Show Hint

The product "except self" is (everything to my LEFT) × (everything to my RIGHT). What did this part call those two summaries?

Show Approach

Two sweeps. Left sweep: answer[i] = product of nums[0..i-1], maintained by a running prefix product. Right sweep: multiply each answer[i] by a running suffix product. Each element ends up holding prefix × suffix — exactly "everything except me". O(n) time, output array only.

Show Solution
Python
def product_except_self(nums):
    n = len(nums)
    answer = [1] * n

    prefix = 1
    for i in range(n):          # answer[i] = product of everything left of i
        answer[i] = prefix
        prefix *= nums[i]

    suffix = 1
    for i in range(n - 1, -1, -1):   # multiply in everything right of i
        answer[i] *= suffix
        suffix *= nums[i]
    return answer

Trace for [1, 2, 3, 4]: after the left sweep answer = [1, 1, 2, 6]; the right sweep multiplies in suffixes 1, 4, 12, 24 → [24, 12, 8, 6] ✅. Time O(n) · Space O(1) beyond the output.

Pattern Recognition
Pattern Insight

"For each i, combine all elements before i with all after i" ⇒ prefix × suffix decomposition. When a per-index answer needs both sides, two linear sweeps beat a nested loop — the same skeleton as leaders (suffix max) and stock (prefix min).

Revision Checklist

PART 4

Strings

Sequences of characters — where the array patterns you just learned learn to speak.

Text is data: names, tweets, DNA bases, log lines, passwords. A string behaves like a read-only array of characters, so every pattern from Part 3 — traversal, frequency tables, two pointers, best-so-far — applies almost unchanged. What is new is one rule with big consequences: strings cannot be modified in place. That rule shapes how we build, compare, and transform text.

A String is a Sequence — read-only array edition

Indexing, slicing, and iteration work exactly as with lists, but assignment into a position does not exist:

Python
s = "python"

s[0]       # 'p'      — first character
s[-1]      # 'n'      — last character
s[1:4]     # 'yth'    — slice: positions 1, 2, 3
s[::-1]    # 'nohtyp' — reversed copy
len(s)     # 6

for ch in s:               # by character
    print(ch)

for i, ch in enumerate(s): # character and its position
    print(i, ch)
Important — Strings are Immutable

s[0] = "b" raises TypeError: 'str' object does not support item assignment. A string is a fixed block of characters; you never change it, you only build a new string from pieces:

Python
s = "cat"
# s[0] = "b"          # ❌ TypeError
s = "b" + s[1:]       # ✅ build a NEW string: "bat"

This is why string algorithms "collect into a list, then join" — lists accept append, strings do not accept anything.

Q1. What happens with s = "cat" followed by s[0] = "b"?
Strings are immutable. You must build a new string ("b" + s[1:]) and rebind the name s to it.

Character Frequency — the dictionary, applied to text

Almost every string question is secretly a counting question: anagrams, unique characters, palindromes-with-rearranging, compression. The tool is the same frequency table from Part 3, now keyed by characters — a character map.

Python
def char_frequency(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    return counts

char_frequency("mississippi")
# {'m': 1, 'i': 4, 's': 4, 'p': 2}

# Another kind of character map: position of the LAST occurrence
last_seen = {}
for i, ch in enumerate("banana"):
    last_seen[ch] = i
# {'b': 0, 'a': 5, 'n': 4}
Concept

A dictionary keyed by character is a character map. The value can be a count ("how many"), an index ("where"), or a flag ("have I seen it"). Choosing the right value type is the algorithmic decision in most string problems.

Comparing Strings — character by character

== checks equal content, not equal identity. Ordering comparisons (<, >) walk both strings left to right and decide at the first differing character — dictionary order (case-sensitive!):

Python
"apple" == "apple"    # True
"apple" == "Apple"    # False — case matters ('a' is 97, 'A' is 65)
"apple" < "banana"    # True  — first chars already differ: 'a' before 'b'
"abc"   < "abd"       # True  — 'a'='a', 'b'='b', then 'c' before 'd'
"ab"    < "abc"       # True  — a prefix is "smaller" than its extension
Tip

Comparing s1 == s2[::-1] is the one-line palindrome test — and comparing a string against its reverse is a recurring trick in many other problems too. Keep s[::-1] (reversed copy) and s[1:] (drop first char) in muscle memory.

Building Strings — += vs join

Because strings are immutable, every result = result + ch may have to copy the entire string built so far into a new block. Appending one character n times copies 1 + 2 + … + n characters — O(n²) work in the worst case. "".join(parts) computes the total size once and copies each character exactly once — O(n), guaranteed.

Python
# ❌ each += may copy everything built so far
result = ""
for ch in text:
    result = result + ch   # worst case: 1+2+...+n copies = O(n^2)
Python
# ✅ append is O(1); join copies each character once
parts = []
for ch in text:
    parts.append(ch)
result = "".join(parts)    # O(n) total
Building 1 char × n timesn = 10,000n = 100,000
result += ch~1 ms~100+ ms
"".join(parts)~0.3 ms~3 ms

Representative timings (CPython, one laptop). Do not memorize the milliseconds — memorize the shape: += grows quadratically, join grows linearly. CPython sometimes optimizes +=, but nothing guarantees it; join is the reliable choice.

Q2. Building a string one character at a time with result += ch in a loop of n iterations is, in the worst case…
Strings are immutable: result + ch builds a brand-new string. Total copying is 1+2+…+n = O(n²). Collect into a list and ''.join once instead.

Palindromes — the friendliest two-pointer problem

A palindrome reads the same forwards and backwards: character 0 must equal character n−1, character 1 must equal character n−2, and so on. That immediately suggests two pointers moving inward — compare the pair, move both ends one step closer, stop when they meet or disagree.

r0L
a1
c2
e3
c4
a5
r6R
"racecar": pair (r, r) matches → move both pointers inward → (a, a) → (c, c) → pointers meet → palindrome ✅

4.1 Valid Palindrome (ignoring case and punctuation)

🟡 L2 · Apply
Problem

Decide whether a string is a palindrome, considering only alphanumeric characters and ignoring case. Examples: "race a car" → False; "A man, a plan, a canal: Panama" → True.

Input / Output

s = "race a car" → False  ·  s = "Madam" → True

Key Observation

Two independent questions tangled together: which characters count (letters and digits, lowercased) and is the sequence symmetric. Solve the first by cleaning (or by skipping while scanning), then the second with the inward two-pointer sweep.

Brute Force

Clean the string into a new one, then compare it with its reverse (cleaned == cleaned[::-1]). Simple and O(n) time — but it allocates two extra strings of size n.

Python
def is_palindrome_simple(s):
    cleaned = [ch.lower() for ch in s if ch.isalnum()]
    return cleaned == cleaned[::-1]

O(n) time, O(n) space. Perfectly acceptable — and the clearest to explain.

Python
def is_palindrome(s):
    left, right = 0, len(s) - 1
    while left < right:
        while left < right and not s[left].isalnum():
            left += 1                  # skip punctuation on the left
        while left < right and not s[right].isalnum():
            right -= 1                 # skip punctuation on the right
        if s[left].lower() != s[right].lower():
            return False
        left += 1
        right -= 1
    return True

O(n) time, O(1) space — no cleaned copy is ever built; punctuation is skipped in place. The inner left < right guards keep the pointers from crossing on strings made of punctuation only, like "!!!".

Dry Run
Dry Run — is_palindrome("race a car")
indices:      0:'r' 1:'a' 2:'c' 3:'e' 4:' ' 5:'a' 6:' ' 7:'c' 8:'a' 9:'r'
left=0, right=9: 'r' vs 'r' ✅ → left=1, right=8
left=1, right=8: 'a' vs 'a' ✅ → left=2, right=7
left=2, right=7: 'c' vs 'c' ✅ → left=3, right=6
left=3, right=6: s[6]=' ' not alnum → right=5
left=3, right=5: 'e' vs 'a' ❌ → return False
r0
a1
c2
e3L
(sp)4
a5R
(sp)6
c7
a8
r9
final comparison: 'e' vs 'a' → mismatch → not a palindrome
Complexity

Time O(n) — each pointer only moves forward · Space O(1) for the two-pointer version, O(n) for the clean-and-compare version.

Edge Cases
  • Empty string "" → True (vacuously a palindrome; the loop never runs).
  • Punctuation only ".,!" → True — after skipping there is nothing left to compare.
  • Single character → True.
  • Mixed case "Madam" → lowercase before comparing, always.
Common Mistakes
  • Forgetting .lower() — "Madam" wrongly fails ('M' vs 'm').
  • Advancing past punctuation without re-checking left < right in the skip loops — pointers can cross and compare garbage.
  • Using strip() and expecting it to remove interior spaces (it only trims the ends).
Pattern Recognition
Pattern Insight

"Reads the same forwards and backwards", symmetry between position i and position n−1−i ⇒ inward two pointers. The same sweep solves reversed comparisons, pair checks, and (later) container problems where the two ends interact.

4.2 Valid Anagram

🟡 L2 · Apply
Problem

Return True if t is an anagram of s — same characters with exactly the same multiplicities, order irrelevant.

Input / Output

s = "listen", t = "silent" → True  ·  s = "rat", t = "car" → False

Key Observation

"Same characters, same counts" is a statement about frequency tables, not about order. Count one string, then walk the other and un-count; any mismatch — a character missing, or a count going below zero — settles it immediately.

Brute Force

For each character of t, search for and remove one matching character from a copy of s. Every removal is an O(n) scan — O(n²) overall.

Better Idea → Optimal

One frequency dictionary does both jobs: increments for s, decrements for t. The repeated work that disappears: searching for a matching character for every position. One caveat first: if the lengths differ, no counting is needed at all — fail fast.

Python Solution
Python
def is_anagram(s, t):
    if len(s) != len(t):
        return False                 # different sizes can never match

    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1

    for ch in t:
        if counts.get(ch, 0) == 0:   # t needs a character s can't supply
            return False
        counts[ch] -= 1
    return True                      # every count hit exactly zero
Dry Run
Dry Run — is_anagram("listen", "silent")
after counting s: {l:1, i:1, s:1, t:1, e:1, n:1}
consume 's': 1 → 0
consume 'i': 1 → 0
consume 'l': 1 → 0
consume 'e': 1 → 0
consume 'n': 1 → 0
consume 't': 1 → 0
all counts exactly zero → True ✅

is_anagram("rat", "car"):
counts {r:1, a:1, t:1}; consume 'c': counts.get('c', 0) == 0 → False ✅
letters suppliest consumesleft over
l110
i110
s110
t110
e110
n110
Complexity

Time O(n) — two linear passes · Space O(k) for the counts, where k is the number of distinct characters (at most 26 for lowercase letters, so effectively O(1) for a fixed alphabet).

Edge Cases
  • Different lengths → False before any counting.
  • Empty vs empty → True.
  • Repeated letters "aab" vs "aba" → True; "aab" vs "ab" → length check catches it.
Common Mistakes
  • Skipping the length check — it is a free O(1) early exit.
  • Counting both strings in separate dictionaries and comparing — also correct, but the one-dictionary increment/decrement version exits early on the first mismatch.
  • Forgetting that equality of sets is not enough: "aabb" and "ab" share the same letter set but are not anagrams. Counts, not membership, decide.
Pattern Recognition
Pattern Insight

"Same letters, possibly rearranged" ⇒ frequency map comparison. Any problem where order is declared irrelevant but composition matters is a counting problem in disguise.

Q3. In is_anagram, why check len(s) != len(t) first?
Anagrams must use every character exactly the same number of times, so total counts must match. The check costs O(1) and often saves the entire scan.

4.3 First Non-Repeating Character

🟡 L2 · Apply
Problem

Return the index of the first character that appears exactly once in the string; return -1 if there is none.

Input / Output

s = "swiss" → 1 (the 'w')  ·  s = "aabb" → -1

Key Observation

Two questions, two different orders: how often does each character appear (any order works — count in one pass) and which is the first with count 1 (must scan in the original order). This is the classic two-pass pattern: preprocess, then query.

Brute Force

For each character, count its occurrences with s.count(ch) — an O(n) scan per character, O(n²) total. What repeats: recounting the same characters over and over.

Optimal Beginner Solution
Python
def first_unique_char(s):
    counts = {}
    for ch in s:                          # pass 1: count everything
        counts[ch] = counts.get(ch, 0) + 1

    for i, ch in enumerate(s):            # pass 2: first with count 1
        if counts[ch] == 1:
            return i
    return -1
Dry Run
Dry Run — first_unique_char("swiss")
pass 1: counts = {s:3, w:1, i:1}
pass 2:
  i=0, ch='s' → count 3  ❌
  i=1, ch='w' → count 1  ✅ return 1
Complexity

Time O(n) — two passes · Space O(k) distinct characters, O(1) for a fixed alphabet. The O(n²) brute force collapses because counting happens once, not once per position.

Edge Cases
  • No unique character ("aabb") → -1.
  • Single character "z" → 0.
  • The unique characters must be reported by position, not by frequency — that is why pass 2 walks the string, not the dictionary (dicts preserve insertion order in modern Python, but relying on that here makes the logic harder to reason about).
Common Mistakes
  • Returning the character instead of its index.
  • Scanning the dictionary in pass 2 — you get "first-inserted", which equals string order only by coincidence of implementation; iterating enumerate(s) is explicit and safe.
  • Doing s.count(ch) inside the second loop — quietly O(n²) again.
Pattern Recognition
Pattern Insight

"First … with property P" where P is global (frequency) ⇒ count first, then scan in order. Whenever a question mixes a global statistic with an ordered query, reach for the two-pass shape.

4.4 Reverse Words in a String

🟡 L2 · Apply
Problem

Reverse the order of the words (not the letters), with single spaces between them. Leading/trailing/multiple spaces collapse to one.

Input / Output

s = "the sky is blue" → "blue is sky the"  ·  s = " hello world " → "world hello"

Key Observation

Words are the atoms here, so first split the string into its word list, then reverse the list, then glue it back. Python's split() with no arguments splits on runs of whitespace and drops the empties — the space normalization comes for free.

Brute Force

Walk the string manually, spotting word boundaries, pushing characters onto stacks of stacks… doable, error-prone, and unnecessary: split already tokenizes correctly.

Optimal Beginner Solution
Python
def reverse_words(s):
    words = s.split()        # ['the', 'sky', 'is', 'blue']
    words.reverse()          # ['blue', 'is', 'sky', 'the']
    return " ".join(words)   # 'blue is sky the'
Dry Run
Dry Run — reverse_words("the sky is blue")
s.split()      → ['the', 'sky', 'is', 'blue']
words.reverse() → ['blue', 'is', 'sky', 'the']
" ".join(words) → "blue is sky the"  ✅

reverse_words("  hello   world  "):
s.split() → ['hello', 'world']   (extra spaces vanish)
→ "world hello"  ✅
Complexity

Time O(n) — split, reverse, and join each touch every character once · Space O(n) for the word list (unavoidable: the output string is new anyway, because strings are immutable).

Edge Cases
  • All spaces " " → "" (split returns an empty list).
  • One word → returned unchanged, trimmed.
  • Tabs and newlines count as whitespace for split().
Common Mistakes
  • Using s.split(" ") — splitting on a single space keeps empty strings for consecutive spaces: "a b".split(" ") is ['a', '', 'b'], and your output grows phantom gaps.
  • Reversing letters of the whole string first and getting confused — reverse the word list, not the characters.
  • Building the result with += inside a loop instead of join (see the benchmark above).
Pattern Recognition
Pattern Insight

"Treat words/segments as units" ⇒ tokenize (split) → process the list → re-join. The tokenize-transform-join pipeline handles reversal, filtering, capitalization, and dedup of words with the same three steps.

4.5 Longest Common Prefix (vertical scan)

🟡 L2 · Apply
Problem

Given a list of strings, return the longest string that every string starts with. If there is none, return the empty string.

Input / Output

strs = ["flower", "flow", "flight"] → "fl"  ·  strs = ["dog", "racecar", "car"] → ""

Key Observation

Column by column: the prefix can only contain column c if every string has the same character there. Scan the first string's characters top-to-bottom across all words — the first column disagreement (or the shortest word running out) marks the end of the prefix.

Brute Force

Compare every pair of strings to find their common prefix, then intersect those results — O(n·m²) in the worst case, with lots of repeated comparisons.

Optimal Beginner Solution
Python
def longest_common_prefix(strs):
    if not strs:
        return ""
    for i, ch in enumerate(strs[0]):
        for word in strs[1:]:
            if i >= len(word) or word[i] != ch:
                return strs[0][:i]   # everything before column i matched
    return strs[0]                   # the first word IS the prefix
Dry Run
Dry Run — longest_common_prefix(["flower", "flow", "flight"])
column 0: 'f' — "flow" has 'f' ✅, "flight" has 'f' ✅
column 1: 'l' — "flow" has 'l' ✅, "flight" has 'l' ✅
column 2: 'o' — "flow"  has 'o' ✅, "flight" has 'i' ❌
          → return strs[0][:2] = "fl" ✅

longest_common_prefix(["dog", "racecar", "car"]):
column 0: 'd' — "racecar" has 'r' ❌ → return strs[0][:0] = "" ✅
f0
l1
o2i=2
w3
e4
r5
strs[0] = "flower": columns 0–1 match everywhere; column 2 breaks on "flight" ('i') → answer "fl"
Complexity

Time O(n·m) — n strings, up to m columns checked (worst case: all strings identical) · Space O(1) beyond the returned slice.

Edge Cases
  • Empty list → "".
  • One string → itself (the loop over the other words never runs).
  • A word shorter than the prefix candidate, e.g. ["flower", "flow"] — the i >= len(word) guard returns "flow" exactly when the short word runs out.
  • Empty string in the list, ["", "b"] → the outer loop body never executes (strs[0] is empty) → "".
Common Mistakes
  • Indexing word[i] without the length guard — IndexError on the shortest word.
  • Returning strs[0][:i] at the right place but forgetting the final return strs[0] when every column matches.
  • Sorting-based tricks (compare only first and last after sorting) — clever but harder to justify; the vertical scan is the version to master first.
Pattern Recognition
Pattern Insight

"Common to ALL strings" ⇒ check a column against every word before extending the answer — incremental agreement. The same vertical shape solves "longest common suffix" and per-column votes of any kind.


Substring Thinking — windows inside the text

A substring is a contiguous run of characters (a subarray of the string); a subsequence may skip characters. A string of length n has n·(n+1)/2 substrings, and Python checks membership with pattern in text — backed by an efficient internal algorithm. The beginner way to understand what it does is to write the naive version once:

Python
def naive_contains(text, pattern):
    n, m = len(text), len(pattern)
    for i in range(n - m + 1):        # every possible start
        if text[i:i + m] == pattern:  # compare the window (length m)
            return i
    return -1

naive_contains("hello", "ll")   # 2
naive_contains("aaaa", "ab")    # -1
Concept

Every start position i (n−m+1 of them) is tested with an m-character comparison — the naive search is O(n·m). It is the subarray brute force wearing a text costume: all windows, scored one by one. Faster searches exist (the book returns to this idea later), but the sliding-window vocabulary starts here.


Quick Drills — one pattern each, under a minute

Drill 4.1 — Reverse a String (two pointers)

🟢 L1

Return the reversed string without using s[::-1]. Example: "abcd" → "dcba".

Show Hint

Strings are immutable, so swap in a list of characters, then join.

Show Solution
Python
def reverse_string(s):
    chars = list(s)
    left, right = 0, len(chars) - 1
    while left < right:
        chars[left], chars[right] = chars[right], chars[left]
        left += 1
        right -= 1
    return "".join(chars)

Time O(n) · Space O(n) for the char list (immutability makes true O(1)-space string reversal impossible in Python).

Drill 4.2 — Count Vowels and Consonants

🟢 L1

Count vowels (a, e, i, o, u) and consonants separately, ignoring non-letters. Example: "Hello World" → (3, 7).

Show Hint

Lowercase first, then two filters: isalpha() to keep letters, membership in "aeiou" to split them.

Show Solution
Python
def count_vowels_consonants(s):
    vowels = "aeiou"
    v = c = 0
    for ch in s.lower():
        if ch.isalpha():
            if ch in vowels:
                v += 1
            else:
                c += 1
    return v, c

# "hello world": vowels e,o,o = 3; consonants h,l,l,w,r,l,d = 7

Time O(n) · Space O(1).

Drill 4.3 — Character Frequency

🟢 L1

Return a dict mapping each character to its count. Example: "mississippi" → {'m': 1, 'i': 4, 's': 4, 'p': 2}.

Show Hint

The character map from the top of this part — counts.get(ch, 0) + 1.

Show Solution
Python
def char_frequency(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    return counts

Time O(n) · Space O(k) distinct characters. (collections.Counter(s) is the built-in equivalent — worth knowing, and worth being able to rebuild by hand.)

Drill 4.4 — Is One String a Rotation of the Other?

🟡 L2

Decide whether s2 is some rotation of s1: cutting s1 anywhere and swapping the pieces can produce s2. Example: "abcde", "cdeab" → True; "abcde", "abced" → False.

Show Hint

Concatenate s1 with itself: "abcdeabcde". Every rotation of s1 — including "cdeab" — lives somewhere inside that doubled string.

Show Solution
Python
def is_rotation(s1, s2):
    if len(s1) != len(s2):
        return False
    return s2 in s1 + s1

# "cdeab" in "abcdeabcde" → True ✅
# "abced" in "abcdeabcde" → False ✅

Time O(n) with Python's internal substring search · Space O(n) for the doubled string. The length check matters: without it, "ab" would pass as a "rotation" of "abab".

Drill 4.5 — Compress Repeated Characters

🟡 L2

Run-length encode: each run of identical characters becomes the character followed by its count. Example: "aaabbc" → "a3b2c1".

Show Hint

Compare each character with the previous one — same run, grow the counter; new character, flush "char + count" and reset. Flush once more after the loop for the final run.

Show Solution
Python
def compress(s):
    if not s:
        return ""
    parts = []
    count = 1
    for i in range(1, len(s)):
        if s[i] == s[i - 1]:
            count += 1
        else:
            parts.append(s[i - 1] + str(count))  # flush the finished run
            count = 1
    parts.append(s[-1] + str(count))             # the last run has no successor
    return "".join(parts)

Dry run on "aaabbc": runs a(3), b(2), c(1) → "a3b2c1" ✅. Time O(n) · Space O(n) for the parts list. Note the classic off-by-one trap this pattern must always solve: the final run is flushed after the loop.

Drill 4.6 — Remove Duplicate Characters

🟢 L1

Keep each character's first occurrence, in order. Example: "banana" → "ban".

Show Hint

Exactly the remove-duplicates pattern from Part 3, with a string glued at the end.

Show Solution
Python
def remove_duplicate_chars(s):
    seen = set()
    result = []
    for ch in s:
        if ch not in seen:
            seen.add(ch)
            result.append(ch)
    return "".join(result)

Time O(n) · Space O(k). Build the list, join once — never result += ch.

Drill 4.7 — Count Words

🟢 L1

Count the words in a string, where words are separated by any amount of whitespace. Example: " multiple spaces " → 2.

Show Hint

split() with no arguments splits on runs of whitespace and returns no empty pieces — the count falls out of the list length.

Show Solution
Python
def count_words(s):
    return len(s.split())

# "  multiple   spaces ".split() → ['multiple', 'spaces'] → 2 ✅

Time O(n) · Space O(n) for the token list.

Drill 4.8 — Basic Substring Search

🟡 L2

Return the index of the first occurrence of pattern in text, or -1. Example: ("hello", "ll") → 2.

Show Hint

Slide a window of length m across every start position; compare each window to the pattern — the naive search from the Substring Thinking section.

Show Solution
Python
def naive_contains(text, pattern):
    n, m = len(text), len(pattern)
    for i in range(n - m + 1):
        if text[i:i + m] == pattern:
            return i
    return -1

# built-ins: pattern in text  /  text.find(pattern)

Time O(n·m) worst case · Space O(1). The last start position is n − m, hence range(n − m + 1).

What You Learned

  • Strings are read-only sequences: indexing, slicing, and traversal match lists, but assignment into a position is a TypeError.
  • Immutability dictates the build pattern: collect into a list, then "".join — O(n) instead of a worst-case O(n²) += loop.
  • Character maps (char → count, index, flag) solve anagram-style questions in two linear passes.
  • Inward two pointers test symmetry (palindromes) in O(n) time, O(1) space.
  • Tokenize → transform → join handles word-level surgery like reversing words and normalizing spaces.
  • Vertical column scans answer "common to all strings" questions such as longest common prefix.
  • s2 in s1 + s1 is the elegant rotation test; split() with no arguments is the safe whitespace tokenizer.

Pattern Recognition

Translate the wording: "reads the same both ways" ⇒ inward two pointers; "same letters, rearranged / permutation of" ⇒ frequency map equality; "first character with a property" ⇒ count once, then scan in order; "reverse / normalize words" ⇒ split, process the list, join; "common start shared by all" ⇒ vertical column scan; "appears inside" ⇒ sliding window comparison. And every time a loop builds a string with +=, stop and reach for a parts list with join.

Complexity Summary

ProblemTimeSpace
Traversal / frequency tableO(n)O(k) distinct chars
Building via "".joinO(n)O(n)
Building via += loopO(n²) worst caseO(n)
Valid palindrome (two pointers)O(n)O(1)
Valid anagram (one dict)O(n)O(k)
First non-repeating characterO(n)O(k)
Reverse words (split/join)O(n)O(n)
Longest common prefix (vertical)O(n·m)O(1)
Rotation check (s2 in s1+s1)O(n)O(n)
Naive substring searchO(n·m)O(1)

Common Mistakes

  • Trying to assign into a string (s[0] = "x") instead of building a new one.
  • Concatenating in a loop with += instead of list + join.
  • Comparing case-sensitively when the problem says "ignore case" — lowercase before comparing.
  • Using s.split(" ") and inheriting empty strings between multiple spaces.
  • Confusing membership (shared letters) with counts (anagram requires multiplicities).
  • Indexing word[i] in multi-string scans without checking i < len(word).
  • Forgetting to flush the final run in run-length style problems.

Mini Quiz

MQ1. What does "abc" < "abd" evaluate to, and why?
Ordering compares character by character and decides at the first difference. (Sum of codes is irrelevant — no "adding up" happens.)
MQ2. "a b c".split() returns…
With no argument, split() splits on runs of whitespace and drops empties. split(" ") would have produced the empty string in the list.
MQ3. How many passes does "first non-repeating character" need, and what does each do?
Counting is order-independent, but "first" depends on the original order — so the query must re-walk the string after the table is ready.
MQ4. Which one-liner correctly tests whether s2 is a rotation of s1 (equal lengths assumed)?
Doubling s1 lines up every rotation as a substring. (The last option tests something entirely different — whether the strings share a common building block.)
MQ5. In the two-pointer palindrome scan, when a character is not alphanumeric, the code…
Each skip loop advances only its own pointer, re-testing the alnum condition, and the left < right guard stops the pointers from crossing.

Practice Problems

Practice 4.1 — Find the Difference

🟢 L1

String t is s with one extra character shuffled in. Return that character. Example: s = "abcd", t = "abcde" → "e".

Show Hint

It is the anagram machinery, run until one count cannot be paid.

Show Approach

Count s into a dict; walk t decrementing. The first character whose count is already 0 is the extra one — return it immediately.

Show Solution
Python
def find_the_difference(s, t):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    for ch in t:
        if counts.get(ch, 0) == 0:
            return ch
        counts[ch] -= 1
    return ""   # unreachable for valid inputs

Time O(n) · Space O(k).

Practice 4.2 — Reverse Only Letters

🟡 L2

Reverse the letters of a string while every non-letter stays in its original position. Example: "ab-cd" → "dc-ba".

Show Hint

Palindrome two pointers, but a pointer that lands on a non-letter waits while the other side keeps moving.

Show Approach

Convert to a list (immutability!). Advance left past non-letters, retreat right past non-letters, swap the two letters, close in. Join at the end.

Show Solution
Python
def reverse_only_letters(s):
    chars = list(s)
    left, right = 0, len(chars) - 1
    while left < right:
        if not chars[left].isalpha():
            left += 1
        elif not chars[right].isalpha():
            right -= 1
        else:
            chars[left], chars[right] = chars[right], chars[left]
            left += 1
            right -= 1
    return "".join(chars)

Dry run on "ab-cd": swap a with d → "db-ca"; swap b with c → "dc-ba"; left lands on '-' (non-letter), left advances, pointers meet → done ✅. Time O(n) · Space O(n).

Practice 4.3 — Equal Character Frequencies?

🟢 L1

Return True if every distinct character in s occurs the same number of times. Example: "aabbcc" → True; "aabbc" → False.

Show Hint

Count first. The answer is a property of the collection of counts, not of the characters.

Show Approach

Build the frequency map, then check whether set(counts.values()) has exactly one element.

Show Solution
Python
def all_same_frequency(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    return len(set(counts.values())) == 1

Time O(n) · Space O(k).

Challenge Problem

Challenge 4 — Can Any Permutation Be a Palindrome?

🟠 L3 · Recognize
Problem

Decide whether the characters of s can be rearranged into a palindrome (you do not have to build it). Example: "carrace" → True ("racecar"); "civil" → False.

Show Hint

In a palindrome, characters pair up around the center. Which counts are allowed to be odd, and how many of them?

Show Approach

Build the frequency map. A palindrome arrangement exists iff at most ONE character has an odd count: even-length strings need all counts even, odd-length strings supply exactly one free character for the center. Count the odd counts and compare with 1.

Show Solution
Python
def can_form_palindrome(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1

    odd = 0
    for c in counts.values():
        if c % 2 == 1:
            odd += 1
    return odd <= 1

# "carrace": c:2 a:2 r:2 e:1 → one odd → True ✅
# "civil":  c:1 i:2 v:1 l:1 → three odds → False ✅

Time O(n) · Space O(k). Note the composition: frequency map (4.2) + palindrome structure (4.1) — neither pattern alone is enough.

Pattern Recognition
Pattern Insight

"Can be rearranged into …" ⇒ stop thinking about order entirely and count. Existence questions about arrangements are usually parity questions about counts (even/odd) — here, the number of odd counts.

Revision Checklist

PART 5

Hashing

Teach your program to remember — the single biggest speed-up available to a beginner.

Slow beginner solutions almost always share one habit: searching the same data over and over. "Have I seen this value?" "How many times did it appear?" "Where was it?" Hashing replaces every one of those repeated searches with a near-instant lookup. You already know the two tools — the dictionary and the set — from Python. This part shows you when to reach for them, and why they turn O(n²) solutions into O(n).

Why Hashing Exists

Take the simplest question imaginable: does the value 42 appear in a list? On a plain list there is no shortcut — Python starts at index 0 and walks forward until it finds 42 or reaches the end. That is exactly what 42 in nums does under the hood. The bigger the list, the longer every single check takes:

List length nAverage steps to answer "is 42 there?"
1,000~500
100,000~50,000
10,000,000~5,000,000

A dictionary or set answers the same question in a small constant number of steps, no matter how big it gets. Internally it feeds the value through a quick math function — its hash — which points directly to one slot in memory, like a coat-check ticket pointing to one hook instead of scanning every coat rack.

set lookup
list scan
nested loops

Cost of one "is it there?" check on a 100,000-item collection: set lookup vs list scan, and the cost of comparing every pair with nested loops.

Checking whether a value exists in a set of 100,000 items takes, on average:
The hash points straight at a slot, so the work does not grow with the size of the set. We say "on average" for a honest reason — see the warning below.
Important — say "average" honestly

Dictionary and set lookups are O(1) on average, not guaranteed O(1). If many values collide (their hashes land in the same slot), a single lookup can degrade to O(n). In practice Python's hashing spreads values out so well that collisions almost never pile up — but when you state complexity in an interview, the precise claim is "O(1) average, O(n) worst case".

Meet the Two Tools

Hashing in Python means two structures. They answer different questions, and choosing the right one is most of the thinking:

Dictionary — a frequency table / mapper

A dict stores key → value pairs. The value can be a count, an index, a group — anything. It answers "how many?", "where?", "what maps to what?".

Python
counts = {}
for ch in "banana":
    counts[ch] = counts.get(ch, 0) + 1
print(counts)          # {'b': 1, 'a': 3, 'n': 2}

fixes = {"Jon": "John", "haze": "haze!"}
print(fixes["Jon"])    # John  — one value mapped to another
Set — a membership checker

A set stores only the values, with no extras. Its one job is answering "have I seen this before?" — instantly.

Python
seen = set()
for x in [5, 2, 9, 2]:
    if x in seen:
        print("duplicate:", x)   # duplicate: 2
    seen.add(x)
dictionary {}set set()
Storeskey → value (count, index, group)just the values
Lookup x in sO(1) averageO(1) average
Answers"how many?", "where?", "map to what?""have I seen it?"
Cost you acceptyou must maintain the valuesyou lose all extra info

The Trick That Changes Complexity

Here is the core transformation of this whole part. Watch what repeated work disappears:

Brute force — search a list each time

To check a new element against everything seen so far, scan the seen-so-far list: O(n) per check, done n times → O(n²).

Hashing — let a set remember

Keep the seen values in a set. Each element asks one O(1)-average question → n questions → O(n). The repeated scan simply disappears.

Concrete numbers. Detecting a duplicate by comparing every pair needs about n²/2 comparisons. With n = 100,000 that is ≈ 5,000,000,000 comparisons — minutes of work even at machine speed. The set version performs exactly 100,000 lookups — a fraction of a second:

nNested pairs (≈ n²/2)Seen set (n lookups)
1,000~500,0001,000
100,000~5,000,000,000100,000
1,000,000~500,000,000,0001,000,000
With n = 100,000 numbers, roughly how many comparisons does nested-pair duplicate detection make, and how many lookups does the set version make?
Every element compared with every other is n²/2 ≈ 5 × 10⁹ pairs. The set does one O(1)-average lookup per element: n lookups in total. That is the whole speed-up.
Pattern Insight

Whenever a brute force contains an inner phrase like "scan everything seen so far", "count the occurrences", or "find its partner" — that inner scan is a search, and a hash structure can replace it with an O(1)-average lookup. Clue words in problem statements: how many have I seen pair that sums to first unique appears twice.

The Five Patterns You Will Reuse

Nearly every hashing problem is one of five small patterns wearing a costume. Learn them as named tools — the rest of this part and much of the rest of the book refers to them by name.

Pattern 1 — Frequency Map

Python
def char_counts(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    return counts

print(char_counts("banana"))    # {'b': 1, 'a': 3, 'n': 2}

Grab it when the question involves counts: "how many times?", "same letters?", "most common?", "first one that appears once?".

Pattern 2 — Seen Set

Python
def first_repeat(items):
    seen = set()
    for x in items:
        if x in seen:
            return x          # second time we meet this value
        seen.add(x)
    return None

print(first_repeat([5, 2, 9, 2, 5]))    # 2

Grab it when the question is pure existence: "any duplicates?", "does it loop back on itself?", "which values appear in both?".

Pattern 3 — Complement Lookup

Python
def has_pair_with_sum(nums, target):
    seen = set()
    for x in nums:
        if target - x in seen:     # the partner I need — already here?
            return True
        seen.add(x)
    return False

print(has_pair_with_sum([3, 5, 1, 7], 8))    # True  (3 + 5)

Grab it when you must find two values with a given sum or relation. For each element you compute exactly what partner would complete the pair, then ask the set.

Pattern 4 — Value → Index

Python
def last_indexes(items):
    pos = {}
    for i, x in enumerate(items):
        pos[x] = i             # overwrites — ends holding the LAST index
    return pos

print(last_indexes([4, 2, 4, 7, 2]))    # {4: 2, 2: 4, 7: 3}

Grab it when positions matter: "return the indices", "how far apart are the repeats?", "the most recent previous occurrence".

Pattern 5 — Counting Groups

Python
def group_by_remainder(nums, k):
    groups = {}
    for x in nums:
        key = x % k                    # any property works as the key
        groups.setdefault(key, []).append(x)
    return groups

print(group_by_remainder([10, 13, 7, 8, 21], 3))
# {1: [10, 13, 7], 2: [8], 0: [21]}

Grab it when values must be grouped or counted by a property: same remainder, same sorted letters, same length, same category.

Worked Problems

5.1 Contains Duplicate

🟢 L1 · Understand
Problem

Given a list of integers, return True if any value appears at least twice, otherwise False.

Input / Output

[1, 2, 3, 1] → True · [1, 2, 3, 4] → False

Key Observation

For each element the question is pure membership: "have I seen this value before?". That is precisely the one job a set does in O(1) average — no counts needed, so no dictionary needed.

Brute Force

Compare every pair with nested loops — O(n²). At n = 100,000 that is the ~5 billion comparisons we counted above. The repeated work: re-checking all previous elements by hand.

Optimal Idea

Walk the list once, carrying a seen set. Each element asks the set one question. First repeat → True. Never a repeat → False.

Python Solution
Python
def contains_duplicate(nums):
    seen = set()
    for x in nums:
        if x in seen:          # ask BEFORE adding
            return True
        seen.add(x)
    return False
Dry Run
Dry Run — nums = [1, 2, 3, 1]
x = 1  → 1 in seen? seen = {}        → no   → seen = {1}
x = 2  → 2 in seen? seen = {1}       → no   → seen = {1, 2}
x = 3  → 3 in seen? seen = {1, 2}    → no   → seen = {1, 2, 3}
x = 1  → 1 in seen? seen = {1,2,3}   → YES ✅  return True
Complexity

Time O(n) average · Space O(n) for the set.

Edge Cases
  • Empty list or single element → False (no pair can exist).
  • All elements equal, e.g. [7, 7, 7] → True on the second element.
Common Mistakes
  • Using a list as seen — membership in a list is O(n), so the "fix" is still O(n²).
  • Adding to the set before checking: then [1] would wrongly return True because the element meets itself.
Pattern Recognition
Pattern Insight

"Any value appearing twice" is a membership question ⇒ Seen Set. If the statement asked which value or how many duplicates, you would upgrade to a frequency map — same idea, more stored information.

5.2 Two Sum

🟡 L2 · Apply
Problem

Given a list of integers and a target, return the indices of the two numbers that add up to the target. Exactly one solution exists; the same element may not be used twice.

Input / Output

nums = [2, 7, 11, 15], target = 9 → [0, 1] (2 + 7)

Key Observation

Standing on value x, the partner you need is fixed: need = target − x. So the vague question "is there a pair?" becomes a precise lookup: "have I already seen need?" — a job for Complement Lookup with a value → index dictionary (Pattern 3 + Pattern 4 combined).

Brute Force

Try every pair — O(n²). For each element you re-search the whole rest of the list; that repeated search is what the dictionary removes.

Optimal Idea

One pass. Before storing x, ask the dictionary for target − x. Found → return both indices. Not found → remember x → i and move on.

Python Solution
Python
def two_sum_brute(nums, target):
    n = len(nums)
    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return [i, j]
    return []
Python
def two_sum(nums, target):
    seen = {}                     # value -> index where we saw it
    for i, x in enumerate(nums):
        need = target - x         # the partner that completes the pair
        if need in seen:
            return [seen[need], i]
        seen[x] = i               # remember this value's position
    return []
Dry Run
Dry Run — nums = [2, 7, 11, 15], target = 9
i = 0  x = 2   need = 9 - 2 = 7    seen = {}          → 7 not in seen → store {2: 0}
i = 1  x = 7   need = 9 - 7 = 2    seen = {2: 0}      → 2 IS in seen ✅
                                      return [seen[2], 1] = [0, 1]
Complexity

Time O(n) average · Space O(n) for the dictionary.

Edge Cases
  • Duplicated values like [3, 3] with target 6: at i = 1, need = 3 is already in seen from i = 0 → returns [0, 1] correctly.
  • The answer may use values far apart — the dictionary does not care about distance.
Common Mistakes
  • Storing x before checking need: with nums = [2] and target 4 you would pair the element with itself and return [0, 0]. Check first, store second.
  • Returning values instead of the indices the problem asked for.
  • Scanning nums again for need with nums.index(need) — that is a hidden O(n) search and breaks the whole point.
Pattern Recognition
Pattern Insight

"Find two numbers with a given sum" ⇒ Complement Lookup. The array is unsorted and you need indices, so a dictionary of value → index beats both nested loops and a sort (sorting would scramble the original positions). A sorted array changes the trade-offs — that is the two-pointer story of Part 6.

5.3 Valid Anagram

🟡 L2 · Apply
Problem

Given two strings s and t, return True if t is an anagram of s (same letters, same counts, any order).

Input / Output

s = "anagram", t = "nagaram" → True · s = "rat", t = "car" → False

Key Observation

Order is irrelevant; only the counts of each letter matter. Count one string, subtract the other — a textbook Frequency Map.

Brute Force

sorted(s) == sorted(t) — correct, but O(n log n): it does ordering work the question never asked for.

Optimal Idea

Count every letter of s into a dictionary. Then walk t, decreasing counts. If a letter is missing or its count is already 0 → False. If every letter subtracts cleanly → True.

Python Solution
Python
def is_anagram(s, t):
    if len(s) != len(t):          # different lengths can never match
        return False
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    for ch in t:
        if counts.get(ch, 0) == 0:   # letter missing or used up
            return False
        counts[ch] -= 1
    return True
Tip

Python ships a ready-made frequency map: from collections import Counter — then the whole function is return Counter(s) == Counter(t). Know the manual version first (interviews ask you to build it), then enjoy the shortcut.

Dry Run
Dry Run — s = "anagram", t = "nagaram"
count s: {'a': 3, 'n': 1, 'g': 1, 'r': 1, 'm': 1}

subtract t: 'n' → 0   'a' → 2   'g' → 0   'a' → 1   'r' → 0   'a' → 0   'm' → 0
no letter was missing or exhausted → return True ✅

s = "rat", t = "car":  counts = {'r': 1, 'a': 1, 't': 1}
subtract 'c' → counts.get('c', 0) == 0 → return False ✅
Complexity

Time O(n) · Space O(1) — at most 26 letter keys, whatever the input size.

Edge Cases
  • Different lengths → early False without touching the counts.
  • Empty strings are anagrams of each other → True.
Common Mistakes
  • Subtracting without the get(ch, 0) == 0 guard — counts can go negative and "cancel" wrongly (e.g. "aab" vs "b" would survive a naive subtract-and-check-all-zero).
  • Comparing only the set of letters: set("aabb") == set("ab") is True but the strings are not anagrams — counts matter, not membership.
Pattern Recognition
Pattern Insight

"Same characters in any order" ⇒ Frequency Map. Any time order can be shuffled away, the surviving information is the counts.

5.4 First Unique Character

🟡 L2 · Apply
Problem

Given a string, return the index of the first character that appears exactly once in the whole string. If there is none, return -1.

Input / Output

s = "swiss" → 1 (the 'w') · s = "aabb" → -1

Key Observation

Two questions hiding in one problem: how often does each letter appear? (needs the whole string → frequency map) and which is the first letter with count 1? (needs the original order → second scan). One pass cannot know a letter is unique until it has seen the whole string.

Optimal Idea

Pass 1 builds the frequency map. Pass 2 walks the string in order and returns the index of the first character whose count is exactly 1.

Python Solution
Python
def first_unique_char(s):
    counts = {}
    for ch in s:                       # pass 1: count everything
        counts[ch] = counts.get(ch, 0) + 1
    for i, ch in enumerate(s):         # pass 2: keep original order
        if counts[ch] == 1:
            return i
    return -1
Dry Run
Dry Run — s = "swiss"
pass 1: {'s': 3, 'w': 1, 'i': 1}

pass 2: i = 0  's'  count 3 → skip
        i = 1  'w'  count 1 ✅  return 1
Complexity

Time O(n) — two passes, still linear · Space O(1) — at most 26 keys for lowercase letters.

Edge Cases
  • Every letter repeats → the second pass finds nothing → -1.
  • Single-character string → that character is unique → index 0.
Common Mistakes
  • Trying to answer in one pass with only a set of "repeated" letters — you lose the counts and the first-unique logic breaks on strings like "aab".
  • Returning the character instead of the index.
Pattern Recognition
Pattern Insight

"First … that appears once" = counts + order ⇒ Frequency Map now, order scan after. When a problem needs both a global property and the original order, expect two passes.

5.5 Majority Element

🟢 L1 · Understand
Problem

Given a list where a majority element is guaranteed to exist (it appears more than n/2 times), return that element.

Input / Output

[2, 2, 1, 1, 1, 2, 2] → 2 (appears 4 times, n = 7)

Key Observation

"Appears more than n/2 times" is a counting question. Count everything, then report the value whose count beats len(nums) // 2.

Brute Force

For each element, count its occurrences with an inner loop — O(n²). The inner counting scan is exactly what the frequency map removes.

Optimal Idea

One pass with a frequency map (or equivalently collections.Counter), then one pass over the map to find the winner.

Python Solution
Python
def majority_element(nums):
    counts = {}
    for x in nums:
        counts[x] = counts.get(x, 0) + 1
    for value, c in counts.items():
        if c > len(nums) // 2:
            return value
    return -1        # no majority (problem says this won't happen)
Tip

return max(counts, key=counts.get) grabs the key with the highest count in one line. Since a majority is guaranteed, the winner of that vote is the majority element.

Dry Run
Dry Run — nums = [2, 2, 1, 1, 1, 2, 2]
counts after each x: {2:1} {2:2} {2:2,1:1} {2:2,1:2} {2:2,1:3} {2:3,1:3} {2:4,1:3}

n = 7 → majority needs more than 3 occurrences
'2' has count 4 > 3 ✅  return 2
Complexity

Time O(n) · Space O(n) for the map. (A clever O(1)-space vote-counting trick exists — Boyer–Moore — but for a guaranteed majority, counting is the clear beginner answer.)

Edge Cases
  • Even-length lists: a majority needs strictly more than n/2, e.g. 3 of 4 is not enough — that is why the check is c > len(nums) // 2 and not >=.
Common Mistakes
  • Using >= instead of >: on [1, 1, 2, 2] (no majority exists) it would confidently "return" 1.
  • Sorting first and returning the middle element — it works here, but it hides the counting idea you were meant to practice.
Pattern Recognition
Pattern Insight

"More than half the time" ⇒ Frequency Map. Variants like "top K frequent" or "elements appearing more than n/3 times" are the same map with a different threshold.

Quick Drills

Drill — Intersection of Arrays

🟢 L1

Return the values that appear in both lists, without duplicates: [1,2,2,1] and [2,2] → [2].

Show Hint

Membership in one list, asked by the other — which structure makes membership cheap?

Show Approach

Turn the first list into a set. Walk the second list; every value found in the set goes into a result set (so it is collected only once).

Show Solution
Python
def intersection(nums1, nums2):
    in_first = set(nums1)
    result = set()
    for x in nums2:
        if x in in_first:
            result.add(x)
    return list(result)

print(intersection([1, 2, 2, 1], [2, 2]))    # [2]

Python's in_first & set(nums2) computes the same thing — good to know, but the loop version is what you should be able to write cold.

Drill — Happy Number (beginner version)

🟡 L2

Repeatedly replace n by the sum of the squares of its digits. If you reach 1 the number is happy; if a value repeats forever you never will. Decide if n is happy.

Show Hint

How do you detect "this repeats forever" without looping forever? What told you "I have seen this before" in 5.1?

Show Approach

Keep a seen set of values the process has produced. Stop when n == 1 (happy) or n is already in seen (stuck in a cycle). The set turns "infinite process" into a decidable question.

Show Solution
Python
def is_happy(n):
    seen = set()
    while n != 1 and n not in seen:
        seen.add(n)
        n = sum(int(d) ** 2 for d in str(n))
    return n == 1

print(is_happy(19))    # True:  19 → 82 → 68 → 100 → 1
print(is_happy(2))     # False: cycles 2 → 4 → 16 → … → 4 → …

Drill — Find Duplicate Values

🟢 L1

Return every value that appears more than once: [4,3,2,7,8,2,3,1] → [2, 3].

Show Hint

One set says "I've seen it", another collects the repeats.

Show Approach

Seen Set pattern with two sets: when a value is already in seen, add it to duplicates. The second set also prevents adding the same duplicate twice.

Show Solution
Python
def find_duplicates(nums):
    seen = set()
    duplicates = set()
    for x in nums:
        if x in seen:
            duplicates.add(x)
        else:
            seen.add(x)
    return list(duplicates)

print(find_duplicates([4, 3, 2, 7, 8, 2, 3, 1]))    # [2, 3]

Drill — Count Frequencies

🟢 L1

Count how often each word appears: ["apple","pear","apple","fig","pear","apple"] → {'apple': 3, 'pear': 2, 'fig': 1}.

Show Hint

Pattern 1, verbatim.

Show Solution
Python
def count_frequencies(items):
    counts = {}
    for x in items:
        counts[x] = counts.get(x, 0) + 1
    return counts

print(count_frequencies(["apple", "pear", "apple", "fig", "pear", "apple"]))
# {'apple': 3, 'pear': 2, 'fig': 1}

Drill — Pair With Target Sum (exists?)

🟡 L2

Return True if any two values add up to target: [3, 5, 1, 7], target 8 → True; target 20 → False.

Show Hint

Same as Two Sum, but you only need existence — so the stored value can be nothing at all.

Show Solution
Python
def pair_exists(nums, target):
    seen = set()
    for x in nums:
        if target - x in seen:
            return True
        seen.add(x)
    return False

print(pair_exists([3, 5, 1, 7], 8))     # True  (3 + 5)
print(pair_exists([3, 5, 1, 7], 20))    # False

Compare with 5.2: the algorithm is identical, but storing indices costs a dictionary while storing nothing costs a set. Only pay for information the question asks for.

Which One Do I Grab?

The decision is a single question about what the answer must contain:

The question asks…You need…GrabExample
"how many of each?"countsdictionary (frequency map)anagram, majority, first unique
"have I seen it? / does it exist?"membership onlysetcontains duplicate, happy number
"where is it? / how far apart?"value → positiondictionary of indexTwo Sum, nearby duplicate
"group / pair up by property"key → listdictionary of listsgroup by remainder
Tip

Start from the cheapest structure (set) and add information only when the output demands it: need counts → dictionary; need positions → dictionary of index. Paying for extra stored information you never return is the beginner version of wasted memory.

What You Learned

  • Why list-searching is the hidden bottleneck in brute force — and that a set/dict lookup is O(1) on average, O(n) worst case.
  • That hashing removes the repeated "scan everything seen so far" work: n²/2 comparisons become n lookups.
  • The five reusable patterns: Frequency Map, Seen Set, Complement Lookup, Value → Index, Counting Groups.
  • How to choose between set, dictionary, and dictionary-of-index from the output the problem demands.
  • That check-before-insert order matters (Two Sum's self-pair bug).

Pattern Recognition

Reach for hashing when the statement asks about existence ("any duplicates?", "does a pair exist?"), counts ("how many times?", "anagram?", "majority?"), or positions of values ("return the indices", "distance between repeats"). If your brute force has an inner loop whose only job is to search, that loop is your signal: delete it and ask a hash structure instead.

Complexity Summary

ProblemPatternTimeExtra Space
Contains DuplicateSeen SetO(n) averageO(n)
Two SumComplement Lookup (value → index)O(n) averageO(n)
Valid AnagramFrequency MapO(n)O(1) — at most 26 keys
First Unique CharacterFrequency MapO(n)O(1) — at most 26 keys
Majority ElementFrequency MapO(n)O(n)

Common Mistakes

  • Keeping seen as a list — the O(1)-average lookup quietly becomes O(n) and the whole optimization evaporates.
  • Inserting into the map/set before checking for the complement or duplicate.
  • Using a set when counts are needed (set("aabb") == set("ab") is true, the strings are not anagrams).
  • Saying "dict lookup is always O(1)" — the honest statement is O(1) average, O(n) worst case with collisions.
  • Modifying a dictionary while iterating over it in the same loop.

Mini Quiz

Q1. Which structure best answers "how many times does each word appear?"
Counts are exactly key → value data — the Frequency Map pattern.
Q2. x in seen where seen is a set of 100,000 items costs, on average:
The hash locates a slot directly — the work does not grow with set size. Average O(1); collisions can make it worse, but rarely in practice.
Q3. two_sum([3, 3], 6) with the complement-lookup solution returns:
At i = 0 the need (3) is not yet in seen, so 3 is stored. At i = 1 the need (3) is found → [0, 1]. Duplicates work precisely because we check before storing.
Q4. In Two Sum, why must we check need in seen before inserting x?
If x is stored first, then x's own complement (x itself, when target = 2x) is "already seen" — the element would be matched with itself.
Q5. is_anagram("rat", "car") returns False because:
Subtracting "car" hits 'c' with count 0 — a letter "rat" never contributed. Order never mattered; counts did.

Practice Problems

Practice — Sum of Unique Elements

🟢 L1

Sum the values that appear exactly once: [1, 2, 3, 2] → 4 (1 + 3).

Show Hint

Which pattern tells you how often each value appeared?

Show Approach

Build a frequency map, then add up the keys whose count is exactly 1.

Show Solution
Python
def sum_of_unique(nums):
    counts = {}
    for x in nums:
        counts[x] = counts.get(x, 0) + 1
    total = 0
    for value, c in counts.items():
        if c == 1:
            total += value
    return total

print(sum_of_unique([1, 2, 3, 2]))    # 4

Practice — Ransom Note

🟡 L2

Can the letters of magazine build note (each letter used at most once)? note = "aa", magazine = "aab" → True; note = "aaa" → False.

Show Hint

An anagram check in reverse: subtract instead of compare.

Show Approach

Count the magazine's letters, then subtract the note's. Any letter that runs out → False.

Show Solution
Python
def can_build(note, magazine):
    counts = {}
    for ch in magazine:
        counts[ch] = counts.get(ch, 0) + 1
    for ch in note:
        if counts.get(ch, 0) == 0:
            return False
        counts[ch] -= 1
    return True

print(can_build("aa", "aab"))     # True
print(can_build("aaa", "aab"))    # False

Practice — Contains Nearby Duplicate

🟡 L2

Return True if some value appears twice with indices at most k apart: [1,2,3,1], k = 3 → True; [1,2,3,1,2,3], k = 2 → False.

Show Hint

A set forgets positions. What pattern remembers where a value last appeared?

Show Approach

Value → Index pattern: keep a dict of value → most recent index. On seeing x again, compare the gap; either way, update the stored index (a closer duplicate can only come from the latest one).

Show Solution
Python
def contains_nearby_duplicate(nums, k):
    last_seen = {}                    # value -> most recent index
    for i, x in enumerate(nums):
        if x in last_seen and i - last_seen[x] <= k:
            return True
        last_seen[x] = i
    return False

Challenge Problem

Challenge — Longest Consecutive Sequence

🟠 L3 · Recognize

Given an unsorted list, find the length of the longest run of consecutive values (values may be in any order, duplicates possible): [100, 4, 200, 1, 3, 2] → 4 (1, 2, 3, 4).

Show Hint 1

Sorting would work in O(n log n). Can a set answer "is x+1 there?" fast enough to walk whole runs without sorting?

Show Hint 2

What property identifies the start of a run? Only begin counting from those values — everything else is a waste of steps.

Show Approach

Put all values in a set. A value x starts a run only if x - 1 is absent. From each start, walk x+1, x+2, … while they exist and count. Every value is visited at most twice (once by the outer loop, once inside one run) → O(n) average.

Show Solution
Python
def longest_consecutive(nums):
    values = set(nums)                 # dedupe + O(1)-average membership
    best = 0
    for x in values:
        if x - 1 in values:            # not the start of a run
            continue
        length = 1                     # x starts a run — walk it
        while x + length in values:
            length += 1
        best = max(best, length)
    return best

print(longest_consecutive([100, 4, 200, 1, 3, 2]))    # 4
Dry Run — [100, 4, 200, 1, 3, 2]
set = {1, 2, 3, 4, 100, 200}
x = 1   (0 not in set → start)  walk 1→2→3→4  length 4  best = 4
x = 2,3,4  (x-1 in set → skip)
x = 100  (99 not in set → start) next is 101? no → length 1
x = 200  (199 not in set → start) length 1
answer: 4
Pattern Insight

The set did two jobs at once: O(1)-average membership ("does x−1 / x+1 exist?") and free de-duplication. "Consecutive values, unsorted input" without sorting ⇒ hash set.

Revision Checklist

PART 6

Two Pointers

Two indices, one rule, and every step throws away work you provably never needed.

Pair-shaped questions — "two numbers that sum to…", "remove duplicates", "rearrange in place" — tempt you into nested loops. Two pointers replace the second loop with a rule: instead of trying every partner, you decide after each comparison which side can no longer matter, and discard it. Every step is a small proof that the discarded elements were safe to abandon. That proof is the real content of this part.

Why Two Pointers Exist

Take "find a pair summing to 18" in a list of 100,000 sorted numbers. Checking every pair is ≈ 5 × 10⁹ comparisons — we counted this in Part 5. Hashing fixes it with extra memory and O(n) space. Two pointers fix it a second way: no extra memory at all, still O(n) — but only because the array is sorted, which lets each comparison eliminate an element forever.

2
pointers
O(n)
one sweep, no nesting
O(1)
extra space
Concept

A pointer here is just an index stored in a variable, e.g. left = 0. "Moving a pointer" means left += 1 or right -= 1. The power is not in the variables — it is in the rule that decides when each one moves.

The Two Flavors

Opposite-direction pointers

Start at both ends, walk toward each other. Each comparison removes one end from consideration.

Used for: pair sums in sorted arrays, reversing, palindromes, container-with-most-water.

Text
L →                  ← R
[ 1   2   4   7  11  15 ]
every step moves L right OR R left
Same-direction pointers (read / write)

Both start at the left; a fast read scans everything, a slow write marks where the next kept element belongs. The prefix before write is always settled.

Used for: in-place removal, move zeroes, partitioning — "modify in place" problems.

Text
        write        read
         ↓ ↓          ↓ ↓
[ 1 | 0   0   3  12 ]
settled    under review

Opposite-Direction Pointers

The classic setup. left starts at index 0, right at the last index. Compare what they point at; then exactly one of three things happens:

ComparisonMoveWhat you safely discard
arr[left] + arr[right] < targetleft += 1 → every remaining pair that uses arr[left]
arr[left] + arr[right] > target← right -= 1 every remaining pair that uses arr[right]
arr[left] + arr[right] == targetreturn— done
The key understanding — why moving that pointer is safe

The array is sorted. Suppose the sum is too small: arr[left] + arr[right] < target. Every element between them is ≤ arr[right], so arr[left] paired with any remaining element gives an even smaller sum — left cannot be part of any answer that still exists. Discarding it skips nothing. The too-big case mirrors it: arr[right] paired with anything remaining is ≥ the current too-big sum, so right is useless. Each move throws away only doomed candidates — never the answer.

Common Mistake — using it on an unsorted array

The safety argument leans entirely on sorted order. Watch it fail:

Dry Run — arr = [3, 2, 4] (NOT sorted), target = 6
left = 0 (3), right = 2 (4):  3 + 4 = 7 > 6  → move right ←
left = 0 (3), right = 1 (2):  3 + 2 = 5 < 6  → move left  →
left meets right → "no pair" ❌

…but 2 + 4 = 6 exists! With 3 the smallest element, the discard
rules lie. Sort first — or use hashing (Part 5) if you need the
original indices.
Sorted array, arr[L] + arr[R] > target. Which pointer moves, and why is that safe?
All remaining elements are ≥ arr[L], so pairing arr[R] with any of them keeps the sum too big. R is useless — moving it discards only doomed pairs.

Watch the pointers move — arr = [1, 2, 4, 7, 11, 15], target = 18

10L
21
42
73
114
155R
Step 1 · L = 0, R = 5 → 1 + 15 = 16 < 18 → sum too small → move L →
10
21L
42
73
114
155R
Step 2 · L = 1, R = 5 → 2 + 15 = 17 < 18 → still too small → move L →
10
21
42L
73
114
155R
Step 3 · L = 2, R = 5 → 4 + 15 = 19 > 18 → sum too big → move R ←
10
21
42L
73
114R
155
Step 4 · L = 2, R = 4 → 4 + 11 = 15 < 18 → too small → move L →
10
21
42
73L
114R
155
Step 5 · L = 3, R = 4 → 7 + 11 = 18 ✅ found — return [3, 4]

Five comparisons, one answer — a nested loop would have checked 15 pairs and hashed solutions burn O(n) extra memory. The sorted order did the heavy lifting.

Worked Problems

6.1 Pair Sum in a Sorted Array (Two Sum II)

🟡 L2 · Apply
Problem

Given a sorted (ascending) array and a target, return the indices of the two numbers that add up to the target, or [] if no pair exists.

Input / Output

arr = [1, 2, 4, 7, 11, 15], target = 18 → [3, 4]

Key Observation

A sum that is too small can only be repaired by a bigger first number (move L), a sum that is too big only by a smaller second number (move R). Sortedness turns "which pair next?" into "which end to discard?".

Brute Force

All pairs — O(n²). It re-checks pairs that the sorted order had already ruled out.

Optimal Idea

Pointers at both ends; each comparison deletes one element from the search space. At most n−1 comparisons.

Python Solution
Python
def pair_sum_brute(arr, target):
    n = len(arr)
    for i in range(n):
        for j in range(i + 1, n):
            if arr[i] + arr[j] == target:
                return [i, j]
    return []
Python
def pair_sum_sorted(arr, target):
    left, right = 0, len(arr) - 1
    while left < right:
        current = arr[left] + arr[right]
        if current == target:
            return [left, right]
        elif current < target:
            left += 1        # need a bigger sum -> drop the small end
        else:
            right -= 1       # need a smaller sum -> drop the big end
    return []
Dry Run
Dry Run — arr = [1, 2, 4, 7, 11, 15], target = 18
L = 0, R = 5:  1 + 15 = 16 < 18 → move L →
L = 1, R = 5:  2 + 15 = 17 < 18 → move L →
L = 2, R = 5:  4 + 15 = 19 > 18 → move R ←
L = 2, R = 4:  4 + 11 = 15 < 18 → move L →
L = 3, R = 4:  7 + 11 = 18 ✅  return [3, 4]
Complexity

Time O(n) — each step removes one element · Space O(1).

Edge Cases
  • No pair exists → pointers meet → return [].
  • Duplicates: [2, 2, 3], target 4 → 2+3=5 too big → R--, then 2+2=4 → [0, 1].
  • Two-element array is the smallest valid input; the loop runs exactly once.
Common Mistakes
  • Loop condition left <= right — an element would be paired with itself.
  • Moving both pointers after one comparison: you discard two candidates but only proved one discard — you can skip the answer.
  • Forgetting the sorted-array requirement (see the [3, 2, 4] failure above).
Pattern Recognition
Pattern Insight

sorted input + find a pair ⇒ opposite-direction two pointers. If the array is unsorted and you need original indices, hashing wins; if it is sorted, two pointers wins with O(1) space.

Same-Direction Pointers — read & write

The second flavor modifies an array in place: no new list, O(1) extra space. Two indices move left to right at different speeds:

  • read — the fast pointer. Scans every element, never skips, never stops early.
  • write — the slow pointer. Marks the slot where the next kept element belongs.
Concept — the invariant

The whole technique rests on one sentence you should be able to say out loud: "everything before write is finished and correct; everything between write and read has already been judged." Each loop iteration preserves this. When the loop ends, the answer is exactly the prefix arr[0..write-1].

6.2 Remove Duplicates from a Sorted Array

🟡 L2 · Apply
Problem

Given a sorted array, remove the duplicates in place so each value appears once. Return the count k of unique values; the first k slots must hold the result. Order stays.

Input / Output

[1, 1, 2, 2, 3] → returns 3, array begins [1, 2, 3, _, _]

Key Observation

Sorted ⇒ duplicates sit next to each other. An element is "new" exactly when it differs from the last kept value, arr[write - 1]. So read judges, write files.

Brute Force

Build a new list of uniques, then copy it back — O(n) time but O(n) extra space, and it sidesteps the in-place skill this problem exists to teach.

Optimal Idea

Read/write pointers. The first element is always kept (write = 1). Every read element that differs from the last kept one gets written forward.

Python Solution
Python
def remove_duplicates(arr):
    if not arr:
        return 0
    write = 1                          # slot for the next new unique value
    for read in range(1, len(arr)):
        if arr[read] != arr[write - 1]:   # different from last KEPT value
            arr[write] = arr[read]
            write += 1
    return write
Dry Run
Dry Run — arr = [1, 1, 2, 2, 3]
start:  write = 1                     kept prefix: [1]

read=1: arr[1]=1 == arr[write-1]=1 → duplicate, skip
read=2: arr[2]=2 != arr[0]=1  → arr[1]=2, write=2   [1, 2, _, _, _]
read=3: arr[3]=2 == arr[write-1]=2 → duplicate, skip
read=4: arr[4]=3 != arr[1]=2  → arr[2]=3, write=3   [1, 2, 3, _, _]

return 3 — first 3 slots are [1, 2, 3] ✅
Complexity

Time O(n) · Space O(1) — truly in place.

Edge Cases
  • Empty array → return 0 (the early guard exists only so write = 1 is safe).
  • All identical, e.g. [5, 5, 5] → read never writes → return 1.
  • All unique → every read writes → return len(arr), array unchanged.
Common Mistakes
  • Writing to arr[read] instead of arr[write] — the compaction becomes a no-op and duplicates stay exactly where they were.
  • Returning write + 1 — write already points at the next free slot, so the count is write.
  • Forgetting the array may keep stale values after index k; the contract only guarantees the first k slots.
Pattern Recognition
Pattern Insight

"Remove / compact in place" + "keep order" ⇒ read/write pointers. The moment a problem says O(1) extra space, the write pointer is usually the intended tool.

6.3 Move Zeroes

🟡 L2 · Apply
Problem

Move all zeroes in an array to the end in place, keeping the relative order of the non-zero elements.

Input / Output

[0, 1, 0, 3, 12] → [1, 3, 12, 0, 0]

Key Observation

Same invariant as 6.2, different filter: an element is "kept" exactly when it is non-zero. Swapping (rather than overwriting) automatically parks the zeroes behind write — no second pass to fill zeroes.

Optimal Idea

Read scans all; each non-zero element swaps into the write slot; write advances. Zeroes drift rightward as a side effect.

Python Solution
Python
def move_zeroes(nums):
    write = 0                        # next slot for a non-zero
    for read in range(len(nums)):
        if nums[read] != 0:
            nums[write], nums[read] = nums[read], nums[write]
            write += 1
Dry Run
Dry Run — nums = [0, 1, 0, 3, 12]
read=0: 0 → skip                          [0, 1, 0, 3, 12]  write=0
read=1: 1 ≠ 0 → swap nums[0], nums[1]     [1, 0, 0, 3, 12]  write=1
read=2: 0 → skip
read=3: 3 ≠ 0 → swap nums[1], nums[3]     [1, 3, 0, 0, 12]  write=2
read=4: 12 ≠ 0 → swap nums[2], nums[4]    [1, 3, 12, 0, 0]  write=3

done — zeroes sit at the end, non-zeros kept their order ✅
Complexity

Time O(n) · Space O(1).

Edge Cases
  • All zeroes → no swaps, array unchanged (which is the answer).
  • No zeroes → every element swaps with itself — harmless.
Common Mistakes
  • Using the overwrite version (copy non-zeros forward) and forgetting to zero out the tail.
  • Adding a tail-zeroing pass after the swap version — the zeroes are already there, and the extra pass scrambles them.
  • Advancing write when the element is zero — that breaks the invariant instantly.
Pattern Recognition
Pattern Insight

"Rearrange in place by a rule, preserve order" ⇒ read/write pointers. Swap-into-write-slot is the cleanest pattern when the discarded elements must survive somewhere in the array.

Mid-loop in Move Zeroes, which statement is always true?
That is the invariant. Elements between write and read have been judged; the region after read is untouched. Only the prefix before write is guaranteed.

Opposite-Direction Again — Container With Most Water

The pair-sum rule was "sum too small/big → move accordingly". Container is the same skeleton with a different rule, and beginners deserve the honest version of it.

Lines at indices L and R hold water up to the shorter line — water above the shorter wall spills over. Area = min(h[L], h[R]) × (R − L). Try every pair? O(n²) — for 100,000 lines that is ≈ 5 × 10⁹ pairs. The two-pointer rule: always move the pointer at the shorter line.

Why moving the shorter side is safe

Say h[L] < h[R]. Keeping L and moving R inward only shrinks the width while the limiting height stays h[L] — the area strictly decreases. So no pair (L, anything-farther-right) can beat what we just measured, and L can be safely discarded. Moving R would keep the bottleneck and shrink width — pointless. And because we record the area before every move, the best container using any wall is measured before that wall is ever dropped — the maximum can't slip away.

Watch the water line — heights = [1, 5, 4, 3]

10L
51
42
33R
width 3 → min(1, 3) × 3 = 3 · shorter is L (1) → move L → (best so far 3)
10
51L
42
33R
width 2 → min(5, 3) × 2 = 6 ⭐ · shorter is R (3) → move R ← (best so far 6)
10
51L
42R
33
width 1 → min(5, 4) × 1 = 4 · shorter is R (4) → move R ← pointers meet → stop. Answer 6

6.4 Container With Most Water

🟠 L3 · Recognize
Problem

Each element of heights is a vertical line of that height at position i. Pick two lines so that together with the x-axis they hold the most water. Return that maximum area.

Input / Output

[1, 5, 4, 3] → 6 (lines of 5 and 3, width 2)

Key Observation

Area = min of the two heights × width. The shorter wall is the bottleneck; width only shrinks as pointers move inward. So the only move that can ever improve things is replacing the shorter wall.

Brute Force

Every pair of lines, compute the area, keep the max — O(n²). Fine for 4 lines, fatal for 100,000.

Optimal Idea

Pointers at the ends. Measure, record, then move the shorter side inward. Repeat until they meet.

Python Solution
Python
def max_area(heights):
    left, right = 0, len(heights) - 1
    best = 0
    while left < right:
        width = right - left
        area = min(heights[left], heights[right]) * width
        best = max(best, area)
        if heights[left] < heights[right]:
            left += 1            # shorter wall is on the left
        else:
            right -= 1           # shorter wall on the right (or a tie)
    return best
Dry Run
Dry Run — heights = [1, 5, 4, 3]
L=0, R=3: min(1, 3) × 3 = 3   best = 3   h[L] < h[R] → move L →
L=1, R=3: min(5, 3) × 2 = 6   best = 6   h[L] ≥ h[R] → move R ←
L=1, R=2: min(5, 4) × 1 = 4   best = 6   h[L] ≥ h[R] → move R ←
pointers meet → return 6 ✅  (matches checking all 6 pairs)
Complexity

Time O(n) · Space O(1).

Edge Cases
  • Ties (h[L] == h[R]): move either — the else branch moving R is fine; the tie wall could not raise the limit either way.
  • Strictly increasing heights like [1, 2, 3, 4]: pointer walks L forward each step; brute force agrees the best is (1, 3) with area min(2, 4) × 2 = 4.
Common Mistakes
  • Moving the taller side — it feels active and helpful, and it is exactly the move that can lose the answer.
  • Using width = right - left + 1 — water spans between the lines; two adjacent lines hold width 1, not 2.
  • Using max(h[L], h[R]) as the height — water above the shorter wall spills.
Pattern Recognition
Pattern Insight

"Maximize a quantity over two ends that moves inward" ⇒ opposite-direction pointers with a greedy discard rule. Whenever one side is provably not the limiting factor, drop that side.

Quick Drills

Drill — Reverse an Array In Place

🟢 L1

[1, 2, 3, 4] → [4, 3, 2, 1], no second array.

Show Solution
Python
def reverse_array(arr):
    left, right = 0, len(arr) - 1
    while left < right:
        arr[left], arr[right] = arr[right], arr[left]
        left += 1
        right -= 1

Opposite-direction pointers swapping as they march toward the middle. Stopping at left < right (not <=) avoids a pointless self-swap.

Drill — Palindrome Check

🟢 L1

Is the string the same forwards and backwards? "level" → True, "python" → False.

Show Solution
Python
def is_palindrome(s):
    left, right = 0, len(s) - 1
    while left < right:
        if s[left] != s[right]:
            return False
        left += 1
        right -= 1
    return True

First mismatch proves "no" instantly — pointers can stop early. Extension: skip non-letters and ignore case (see Practice below).

Drill — Remove a Specific Value In Place

🟢 L1

Remove all occurrences of val in place, return the new length: [3, 2, 2, 3], val = 3 → length 2, array begins [2, 2].

Show Solution
Python
def remove_value(nums, val):
    write = 0
    for read in range(len(nums)):
        if nums[read] != val:
            nums[write] = nums[read]
            write += 1
    return write

Move Zeroes with != val as the keep-rule and overwrite instead of swap — nothing needs to survive at the tail, so overwriting is simpler.

Drill — Merge Two Sorted Arrays

🟡 L2

[1, 3, 5] and [2, 4, 6] → [1, 2, 3, 4, 5, 6].

Show Solution
Python
def merge_sorted(a, b):
    i = j = 0
    result = []
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            result.append(a[i])
            i += 1
        else:
            result.append(b[j])
            j += 1
    result.extend(a[i:])     # one of these is empty — the other
    result.extend(b[j:])     # appends whatever is left over
    return result

Two pointers, one per array, always advancing the smaller head. The safety argument you know applies per-array: taking the smaller head never discards anything needed. This loop is the heart of merge sort.

Drill — Simple Partition

🟡 L2

Put every value < pivot before every value ≥ pivot, in place, order within groups irrelevant. Return the split point: [9, 1, 7, 2, 5], pivot 5 → split 2, array begins [1, 2, …].

Show Solution
Python
def partition(nums, pivot):
    write = 0
    for read in range(len(nums)):
        if nums[read] < pivot:
            nums[write], nums[read] = nums[read], nums[write]
            write += 1
    return write

Move Zeroes again, new rule. The invariant survives: before write = everything smaller than the pivot. This is the partition step hiding inside quicksort.

When to Move Which Pointer

SituationMoveWhy it never skips the answer
sorted pair sum too smallleft += 1arr[L] + any remaining ≤ arr[L] + arr[R] < target
sorted pair sum too bigright -= 1arr[R] + any remaining ≥ arr[L] + arr[R] > target
container, left line shorterleft += 1keeping L cannot beat the area just measured
container, right line shorter or tieright -= 1same argument, mirrored
read element qualifies (keep-rule)write it, both advanceinvariant: prefix before write is settled
read element fails (keep-rule)only read advancesread touches every element — nothing goes unexamined
Tip

Before coding any two-pointer solution, say the discard rule and its justification as one sentence: "moving ___ is safe because ___. " If you cannot finish that sentence, the solution is a guess, not an algorithm.

What You Learned

  • The two flavors: opposite-direction (discard an end each step) and same-direction read/write (in-place compaction).
  • Why sorted order is what makes the pair-sum discard rules valid — and how the algorithm fails on unsorted data.
  • The read/write invariant: everything before write is settled; read touches every element.
  • Container with most water: always move the shorter wall, because the taller wall was never the bottleneck.
  • Every two-pointer step must come with the sentence "moving this pointer is safe because…".

Pattern Recognition

Sorted array + pair/target question ⇒ opposite-direction pointers. "In place, keep order, O(1) space" ⇒ read/write pointers. "Maximize over two ends" ⇒ greedy pointer movement with a discard proof. If the array is unsorted and indices must be preserved, go back to hashing (Part 5).

Complexity Summary

ProblemFlavorTimeExtra Space
Two Sum II (sorted pair sum)opposite-directionO(n)O(1)
Remove Duplicates from Sorted Arrayread/writeO(n)O(1)
Move Zeroesread/writeO(n)O(1)
Container With Most Wateropposite-directionO(n)O(1)

Common Mistakes

  • Applying opposite-direction logic to unsorted data — the discard rules become lies.
  • Moving both pointers after one comparison, discarding without proof.
  • Loop condition left <= right where an element must not pair with itself.
  • Advancing write on elements that fail the keep-rule.
  • Forgetting that read must touch every element — an early break in a compaction loop leaves the tail unprocessed.

Mini Quiz

Q1. Sorted pair sum, current sum too small. Which pointer moves?
All remaining elements are ≤ arr[R], so every pair using arr[L] is also too small. Discard L.
Q2. Why does Two Sum II demand a sorted array?
On [3, 2, 4] target 6 the algorithm reports "no pair" although 2 + 4 exists — the rules need order, not the pointer variables.
Q3. In Move Zeroes, what does write track?
write is the boundary of the settled non-zero prefix; each qualifying read element swaps into it.
Q4. Remove Duplicates from a sorted array of n elements costs:
One pass of read over n elements; the write slot reuses the same array. No extra memory at all.
Q5. In Container With Most Water, which pointer moves?
Moving the taller wall keeps the same limiting height and shrinks width — strictly worse. Only replacing the shorter wall can ever help.

Practice Problems

Practice — Squares of a Sorted Array

🟡 L2

Given a sorted array (possibly with negatives), return a sorted array of the squares: [-4, -1, 0, 3, 10] → [0, 1, 9, 16, 100].

Show Hint

Squaring makes negatives positive — the biggest squares live at the two ends of the array, not the middle.

Show Approach

Opposite-direction pointers compare absolute values; the larger square fills the result from the back, so no shifting is ever needed.

Show Solution
Python
def sorted_squares(nums):
    n = len(nums)
    result = [0] * n
    left, right = 0, n - 1
    pos = n - 1
    while left <= right:
        if abs(nums[left]) > abs(nums[right]):
            result[pos] = nums[left] ** 2
            left += 1
        else:
            result[pos] = nums[right] ** 2
            right -= 1
        pos -= 1
    return result

print(sorted_squares([-4, -1, 0, 3, 10]))    # [0, 1, 9, 16, 100]

Practice — Valid Palindrome Sentence

🟢 L1

Is the sentence a palindrome ignoring punctuation, spaces and case? "A man, a plan, a canal: Panama" → True; "race a car" → False.

Show Hint

The pointers skip whatever fails isalnum() instead of building a cleaned copy.

Show Approach

Opposite-direction pointers with skipping: if a side points at punctuation, move that side; otherwise compare lowercase characters; on mismatch return False.

Show Solution
Python
def is_sentence_palindrome(s):
    left, right = 0, len(s) - 1
    while left < right:
        if not s[left].isalnum():
            left += 1
        elif not s[right].isalnum():
            right -= 1
        elif s[left].lower() != s[right].lower():
            return False
        else:
            left += 1
            right -= 1
    return True

Practice — Rotate an Array by k

🟡 L2

Rotate right by k, in place: [1, 2, 3, 4, 5, 6, 7], k = 3 → [5, 6, 7, 1, 2, 3, 4].

Show Hint

Three reversals produce the rotation. You already wrote a reversal with two pointers.

Show Approach

Reverse the whole array, reverse the first k elements, reverse the rest. k %= n first, so k larger than n behaves.

Show Solution
Python
def rotate(nums, k):
    n = len(nums)
    k = k % n
    def reverse(lo, hi):
        while lo < hi:
            nums[lo], nums[hi] = nums[hi], nums[lo]
            lo += 1
            hi -= 1
    reverse(0, n - 1)      # [7, 6, 5, 4, 3, 2, 1]
    reverse(0, k - 1)      # [5, 6, 7, 4, 3, 2, 1]
    reverse(k, n - 1)      # [5, 6, 7, 1, 2, 3, 4]

Challenge Problem

Challenge — 3Sum

🟠 L3 · Recognize

Find all unique triples in the array that sum to 0: [-1, 0, 1, 2, -1, -4] → [[-1, -1, 2], [-1, 0, 1]]. No duplicate triples in the output.

Show Hint 1

Fix one number. The remaining question — "two numbers summing to −fixed" in a sorted array — is exactly 6.1.

Show Hint 2

Duplicates only cause trouble when a value repeats adjacent in sorted order. Skip those, both for the fixed number and for the left pointer.

Show Approach

Sort. For each i, run two pointers on i+1 … n-1 for target -nums[i]. On a hit, record the triple and advance both pointers, skipping repeated values. Skip repeated nums[i] too. Sorting costs O(n log n); the sweep is O(n²) total.

Show Solution
Python
def three_sum(nums):
    nums.sort()
    result = []
    n = len(nums)
    for i in range(n - 2):
        if i > 0 and nums[i] == nums[i - 1]:
            continue                      # same fixed value as before
        left, right = i + 1, n - 1
        while left < right:
            total = nums[i] + nums[left] + nums[right]
            if total == 0:
                result.append([nums[i], nums[left], nums[right]])
                left += 1
                right -= 1
                while left < right and nums[left] == nums[left - 1]:
                    left += 1             # skip duplicate second value
            elif total < 0:
                left += 1
            else:
                right -= 1
    return result
Dry Run — [-1, 0, 1, 2, -1, -4] (sorted: [-4, -1, -1, 0, 1, 2])
i=0 (-4):  l=1 (-1), r=5 (2) → -4-1+2 = -3 < 0 → l++
           l=2 (-1), r=5 (2) → -4-1+2 = -3 < 0 → l++
           l=3 (0),  r=5 (2) → -4+0+2 = -2 < 0 → l++
           l=4 (1),  r=5 (2) → -4+1+2 = -1 < 0 → l++   l meets r → nothing
i=1 (-1):  l=2 (-1), r=5 (2) → -1-1+2=0 ✅ [-1, -1, 2]  l++, r-- → skip dup l=3
           l=3 (0),  r=4 (1) → -1+0+1=0 ✅ [-1, 0, 1]   l++, r-- → cross
i=2 (-1):  same fixed value as i=1 → skip
i=3 (0):   l=4 (1), r=5 (2) → 3 > 0 → r-- → cross
answer: [[-1, -1, 2], [-1, 0, 1]] ✅
Pattern Insight

"Find all triples/quadruplets summing to T" ⇒ sort once, fix the outer value(s), two-pointer the inner pair. Each nesting level adds one fixed pointer, not one full loop of pair-checking.

Revision Checklist

PART 7

Sliding Window

Reuse yesterday's work instead of recomputing it — one of the most elegant O(n²) → O(n) tricks.

Many problems ask about a contiguous stretch of an array or string: the biggest sum of k neighbors, the longest run of distinct characters, the shortest stretch reaching a total. Checking every such stretch from scratch is O(n²) or worse — and almost all of that work is recomputing things the previous stretch already knew. The sliding window keeps one stretch alive, slides it one step at a time, and updates it with an add and a remove. This part is deliberately slow-paced: windows reward precision more than speed.

What Is a Window?

A window is a contiguous block of elements from index left to index right (both included). Here the window is the block [5, 1, 3] at indices 2–4:

20
11
52L
13
34R
25
Window = indices L = 2 … R = 4, the contiguous run [5, 1, 3]
Common Mistake — window ≠ subsequence

A window is contiguous: elements keep their positions, no gaps, no reordering. [2, 5, 3] is not a window of [2, 1, 5, 1, 3, 2] — the 2 and 5 are not neighbors. If a problem says "subsequence", a sliding window does not apply; if it says "subarray" or "substring", it might.

What Repeats in the Naive Solution

Task: maximum sum of any 3 neighboring elements in [2, 1, 5, 1, 3, 2]. The beginner approach sums every window from scratch:

Python
def max_sum_naive(arr, k):
    best = 0
    for left in range(len(arr) - k + 1):
        window_sum = sum(arr[left:left + k])   # re-adds k elements every time
        best = max(best, window_sum)
    return best

Watch what the naive loop actually does between two consecutive windows:

The wasted work — k = 3
window ending at index 2:  2 + 1 + 5  = 8
window ending at index 3:  1 + 5 + 1  = 7     ← 1 and 5 were JUST added one step ago

That is the repeated work: re-adding elements the previous window already summed. For an array of n elements and window size k, the naive method does about n × k additions. When k grows with n, that is O(n²) — on n = 100,000, roughly 10 billion additions for a task that needs 200,000. The fix is not a clever formula; it is a change of bookkeeping.

Fixed-Size Windows

When every window has the same size k, sliding one step changes the sum by exactly two elements: one enters on the right, one leaves on the left.

Concept — the slide, in one line

window_sum += arr[right] − arr[left_old]. The element entering through the right door pays the element leaving through the left door. Everything in between is untouched — that is the work we stopped repeating.

Python
def max_sum_k(arr, k):
    window = sum(arr[:k])            # the first window, computed honestly once
    best = window
    for right in range(k, len(arr)):
        window += arr[right] - arr[right - k]   # add entering, drop leaving
        best = max(best, window)
    return best

Follow the window across the array — arr = [2, 1, 5, 1, 3, 2], k = 3:

20L
11
52R
13
34
25
Window 1 · [2, 1, 5] · sum = 8 · best = 8
20
11L
52
13R
34
25
Slide · +arr[3] −arr[0] → +1 −2 · [1, 5, 1] · sum = 7 · best = 8
20
11
52L
13
34R
25
Slide · +arr[4] −arr[1] → +3 −1 · [5, 1, 3] · sum = 9 ⭐ · best = 9
20
11
52
13L
34
25R
Slide · +arr[5] −arr[2] → +2 −5 · [1, 3, 2] · sum = 6 · best stays 9 — answer 9
Dry Run — arr = [2, 1, 5, 1, 3, 2], k = 3
first window [2, 1, 5]        window = 8    best = 8
right = 3:  8 + arr[3] - arr[0]  =  8 + 1 - 2 = 7    best = 8
right = 4:  7 + arr[4] - arr[1]  =  7 + 3 - 1 = 9 ⭐  best = 9
right = 5:  9 + arr[5] - arr[2]  =  9 + 2 - 5 = 6    best = 9
answer: 9  — the window [5, 1, 3]

Four windows, four updates of two operations each, instead of 4 × 3 = 12 additions. On big inputs the saving is the whole ballgame: n − k + 1 windows × O(1) update = O(n).

Why is nothing missed? The first window covers positions 0…k−1, and each slide moves the window exactly one position right. A window of size k can only start at positions 0, 1, 2, … — and the loop visits every one of them exactly once. Same windows, same totals, radically less arithmetic.

A fixed window of size k has sum 20. It slides one step: the element entering on the right is 6, the element leaving on the left is 2. The new sum is:
One in, one out: 20 + 6 − 2 = 24. Two operations replace k − 1 re-additions.

Variable-Size Windows

Now the window size is not given — the problem asks for the longest or shortest window that satisfies a condition ("no repeated characters", "sum at least 7"). The mechanics change:

1

Expand right. The right pointer advances every loop iteration, pulling a new element into the window and updating the window state.

2

Shrink left while invalid. If the window now breaks the condition, advance left (dropping elements) until the condition holds again. This is a while, not an if — one removal may not be enough.

3

Record the answer. With the window valid, update the best answer using right − left + 1 (the current length).

Concept — the window invariant

Every variable-size window loop maintains an invariant: a statement that is true at the top and bottom of every iteration. Example: "the window [left..right] contains no repeated characters." Expanding may break the invariant; shrinking restores it; the answer is read only while it holds. Write the invariant down before coding — the rest of the code is just enforcing it.

Why nothing is missed when left jumps ahead

Two honest arguments, both worth internalizing:

  • Every end position is served. Right visits each index exactly once. For that right, left stops at the smallest position that keeps the window valid — so we measure the best window ending at that right. Since every possible window ends at some index, the best one is measured when its own right end is reached.
  • Shrinking is safe, not desperate. When the window is invalid, every window that starts at the current left (or anything left of it) and ends at or beyond this right is also invalid — they contain the same offending elements or more. Discarding those starts loses nothing.

Cost: right moves n times, and left only ever moves forward, so left moves at most n times in total across the whole run. That is why the nested-looking loop is still O(n) — the classic "two pointers each walk the array once" argument.

Pseudo-code
# the universal variable-window skeleton
left = 0
state = INITIAL STATE          # sum, Counter, set — whatever the question needs
best = INITIAL ANSWER
for right in range(len(data)):
    ADD data[right] to state            # expand
    while state is INVALID:
        REMOVE data[left] from state
        left += 1                       # shrink until valid again
    UPDATE best using window length (right - left + 1)

What State Lives Inside the Window?

The "state" is whatever quantity the question asks about, kept up to date incrementally:

Question typeState to maintainUpdate on add / remove
max / min / average sum of a windowa running sum+x / −x
repeats allowed or not (strings)a set of chars insideadd / remove
counts of each value insidea dict frequency map (Part 5!)+1 / −1, delete at zero
number of distinct valueslen(counts)follows from the map

Worked Problems

7.1 Maximum Sum Subarray of Size K

🟢 L1 · Understand
Problem

Given an array and an integer k, return the maximum sum of any k contiguous elements.

Input / Output

[2, 1, 5, 1, 3, 2], k = 3 → 9 (the window [5, 1, 3])

Why Is This a Sliding-Window Problem?
Pattern Insight

Why sliding window? The question is over contiguous elements ("subarray") and the window size is fixed (k) — moving one step only swaps one element in and one out.

What state do we maintain? A single number: the current window's sum, updated by + entering − leaving.

Brute Force

Sum each of the n−k+1 windows from scratch — O(n·k). The repeated work is re-adding the k−2 elements two neighboring windows share.

Optimal Idea

Compute the first window once; slide with one addition and one subtraction per step.

Python Solution
Python
def max_sum_naive(arr, k):
    best = 0
    for left in range(len(arr) - k + 1):
        best = max(best, sum(arr[left:left + k]))
    return best
Python
def max_sum_k(arr, k):
    window = sum(arr[:k])            # first window, computed once
    best = window
    for right in range(k, len(arr)):
        window += arr[right] - arr[right - k]
        best = max(best, window)
    return best
Dry Run
Dry Run — arr = [2, 1, 5, 1, 3, 2], k = 3
first window [2, 1, 5]     window = 8                best = 8
right = 3:  8 + 1 - 2 = 7                            best = 8
right = 4:  7 + 3 - 1 = 9 ⭐                         best = 9
right = 5:  9 + 2 - 5 = 6                            best = 9
answer: 9
Complexity

Time O(n) · Space O(1).

Edge Cases
  • k = len(arr) → the single window is the whole array; the loop body never runs.
  • k larger than n → no valid window; guard with an early check in real code.
  • All-negative arrays work fine — the window sum tracks negatives correctly.
Common Mistakes
  • Starting the loop at right = 0 and subtracting arr[right - k] at negative indices — the first window must be built before the loop.
  • Updating best before the first window is complete.
Pattern Recognition
Pattern Insight

subarray + size k + max/min/avg ⇒ fixed-size sliding window with a running sum.

7.2 Longest Substring Without Repeating Characters

🟠 L3 · Recognize
Problem

Given a string, return the length of the longest substring (contiguous!) in which no character appears more than once.

Input / Output

s = "abcabcbb" → 3 (the substring "abc") · s = "bbbbb" → 1

Why Is This a Sliding-Window Problem?
Pattern Insight

Why sliding window? "Longest … substring" is a longest-window question over contiguous characters, and the condition ("no repeats") is one that survives sliding: adding a character to the right can only create one specific repeat, and removing from the left can only remove them. That locality is exactly what a window exploits.

What state do we maintain? The set of characters currently inside the window (a frequency map from Part 5 in disguise) — plus the left index itself.

Key Observation

The window invariant: all characters in s[left..right] are distinct. When the new s[right] is already in the window, left advances (dropping characters) until that duplicate is gone — then the invariant holds again and the length is measured.

Brute Force

Every start index i, extend j until a repeat appears, track the max — O(n²) pairs with a set check each step. It re-checks characters the previous window already knew about.

Optimal Idea

Expand right every iteration; shrink left only while the newest character is a duplicate inside the window; record the length when valid.

Python Solution
Python
def longest_unique_substring(s):
    in_window = set()      # characters currently inside the window
    left = 0
    best = 0
    for right in range(len(s)):
        while s[right] in in_window:    # duplicate → shrink until gone
            in_window.remove(s[left])
            left += 1
        in_window.add(s[right])         # now safe to admit it
        best = max(best, right - left + 1)
    return best
Dry Run
Dry Run — s = "abcabcbb"
r=0 'a': not in window → add          window "a"    left=0  best=1
r=1 'b': not in window → add          window "ab"           best=2
r=2 'c': not in window → add          window "abc"          best=3
r=3 'a': IN window → remove s[0]='a', left=1
         'a' no longer in window → add  window "bca"        best=3
r=4 'b': IN window → remove s[1]='b', left=2 → add
                                      window "cab"          best=3
r=5 'c': IN window → remove s[2]='c', left=3 → add
                                      window "abc"          best=3
r=6 'b': IN window → remove s[3]='a', left=4; 'b' still in
         → remove s[4]='b', left=5 → add    window "cb"         best=3
r=7 'b': IN window → remove s[5]='c', left=6; 'b' still in
         → remove s[6]='b', left=7 → add    window "b"          best=3
answer: 3

The same run, watched as a window on a b c a b c b b:

a0L
b1
c2R
a3
b4
c5
b6
b7
r = 2 · window "abc" · all distinct · best = 3
a0
b1L
c2
a3R
b4
c5
b6
b7
r = 3 · 'a' was a repeat → left shrank to 1 · window "bca" · best = 3
a0
b1
c2
a3
b4
c5L
b6R
b7
r = 6 · 'b' repeated twice in a row → left shrank twice, to 5 · window "cb" · best stays 3
a0
b1
c2
a3
b4
c5
b6
b7L=R
r = 7 · left shrank all the way to 7 · window "b" · final answer: 3
Complexity

Time O(n) — right moves n times, left moves at most n times in total · Space O(min(n, alphabet size)) for the set.

Edge Cases
  • Empty string → loop never runs → 0.
  • All identical characters, "bbbb" → left always shrinks to right → 1.
  • The while-loop can shrink several steps per character — that is expected, and still O(n) overall.
Common Mistakes
  • Using an if instead of while for the shrink: one removal may not clear the duplicate (see r = 6 above — two removals were needed).
  • Removing s[right] instead of s[left] while shrinking.
  • Measuring the length as right - left and losing one element — inclusive ends need the + 1.
  • Answering with the longest subsequence mindset — repeats may be far apart in a subsequence, but a window cannot skip characters.
Pattern Recognition
Pattern Insight

longest substring + no repeats / condition on contents ⇒ variable-size window whose state is a set or Counter. Any "longest/shortest window satisfying X" where X survives sliding is this template.

7.3 Smallest Subarray With Sum ≥ K

🟡 L2 · Apply
Problem

Given an array of positive numbers and a target k, return the length of the shortest contiguous subarray whose sum is at least k. If none exists, return 0.

Input / Output

[2, 3, 1, 2, 4, 3], k = 7 → 2 (the window [4, 3])

Why Is This a Sliding-Window Problem?
Pattern Insight

Why sliding window? We need the shortest contiguous stretch meeting a sum condition. Because all numbers are positive, sums behave monotonically: extending a window can only grow its sum, shrinking can only shrink it. That monotonicity is what lets pointers sweep once instead of restarting per start index.

What state do we maintain? Just the current window sum — a single integer.

Key Observation

This window answers a minimization, so the recording happens differently: while the window is still valid (sum ≥ k), we record its length and then shrink — every shorter valid window ends at this same right edge, and shrinking finds them all before expanding again.

⚠️ Works only with positive numbers

The shrink logic assumes removing a left element decreases the sum. With negatives that fails: dropping a negative number makes the sum bigger, and "shrinking while valid" would throw away the answer. If the array can contain negatives, this template is unsafe — Part 8 shows the prefix-sum tools that do handle them.

Brute Force

Every start index, extend until the sum reaches k, record the length — O(n²). It re-sums overlapping stretches over and over.

Optimal Idea

Expand right always; while sum ≥ k, record the current length and shrink from the left; expand again.

Python Solution
Python
def smallest_subarray_at_least(arr, k):
    left = 0
    cur = 0                       # current window sum
    best = None                   # no valid window seen yet
    for right in range(len(arr)):
        cur += arr[right]         # expand: pull arr[right] in
        while cur >= k:           # valid → record, then shrink
            length = right - left + 1
            if best is None or length < best:
                best = length
            cur -= arr[left]      # drop the left element
            left += 1
    if best is None:
        return 0
    return best
Dry Run
Dry Run — arr = [2, 3, 1, 2, 4, 3], k = 7
r=0: cur = 2        (2 < 7, keep expanding)
r=1: cur = 5
r=2: cur = 6
r=3: cur = 8  ≥ 7 → record len 4  → shrink: cur = 8-2 = 6,  left=1  (6 < 7 stop)
r=4: cur = 10 ≥ 7 → record len 4 (no change)
                   → shrink: cur = 10-3 = 7, left=2 → 7 ≥ 7 → record len 3 ⭐
                   → shrink: cur = 7-1  = 6, left=3  (stop)
r=5: cur = 9  ≥ 7 → record len 3 (no change)
                   → shrink: cur = 9-2  = 7, left=4 → 7 ≥ 7 → record len 2 ⭐⭐
                   → shrink: cur = 7-4  = 3, left=5  (stop)
answer: 2  — the window [4, 3]

The same run, watched as a window — expand, shrink, expand, shrink:

20L
31
12
23R
44
35
expanded to r = 3 · [2, 3, 1, 2] · sum 8 ≥ 7 ✅ · record length 4 · now shrink
20
31L
12
23R
44
35
dropped the 2 · [3, 1, 2] · sum 6 < 7 ❌ invalid · stop shrinking, expand right
20
31
12L
23
44R
35
expanded to r = 4 · [1, 2, 4] · sum 7 ≥ 7 ✅ · record length 3 ⭐ · shrink again
20
31
12
23
44L
35R
r = 5, after shrinking · [4, 3] · sum 7 ≥ 7 ✅ · record length 2 ⭐⭐ — answer 2
Complexity

Time O(n) — right moves n times; left moves forward only, so at most n times in total · Space O(1).

Edge Cases
  • No window ever reaches k → best stays None → return 0.
  • Single element ≥ k → answer 1, found at the first expand.
  • Whole array needed → the last recorded window is arr[0..n-1].
Common Mistakes
  • Recording once per right and then shrinking without re-recording — the shorter windows [1, 2, 4] and [4, 3] are discovered while shrinking; the record must live inside the while loop.
  • Shrinking before expanding, or shrinking past validity — the while condition cur >= k is the only shrink trigger.
  • Using this template when the array contains negatives (see the warning above).
Pattern Recognition
Pattern Insight

shortest subarray + sum ≥ target + positive numbers ⇒ variable window; record inside the shrink loop for minimization problems. "Longest" variants record after the shrink, once valid.

Quick Drills

Drill — Average of Each Window of Size K

🟢 L1

Return every window's average, in order: [1, 2, 3, 4, 5], k = 2 → [1.5, 2.5, 3.5, 4.5].

Show Solution
Python
def window_averages(arr, k):
    window = sum(arr[:k])
    result = [window / k]
    for right in range(k, len(arr)):
        window += arr[right] - arr[right - k]
        result.append(window / k)
    return result

Same skeleton as 7.1 — only the "best" update became "append".

Drill — Count Even Numbers in Each Window

🟡 L2

For each size-k window, report how many even numbers it contains: [1, 2, 3, 4, 5, 6], k = 3 → [1, 2, 1, 2].

Show Hint

The state is not a sum but a count — the entering and leaving elements each change it by 0 or 1.

Show Solution
Python
def count_evens_per_window(arr, k):
    evens = sum(1 for x in arr[:k] if x % 2 == 0)
    result = [evens]
    for right in range(k, len(arr)):
        if arr[right] % 2 == 0:       # entering element
            evens += 1
        if arr[right - k] % 2 == 0:   # leaving element
            evens -= 1
        result.append(evens)
    return result

print(count_evens_per_window([1, 2, 3, 4, 5, 6], 3))    # [1, 2, 1, 2]

Drill — Longest Substring With At Most K Distinct Characters

🟠 L3

s = "eceba", k = 2 → 3 ("ece"). The condition is now an upper bound on the state.

Show Hint

Frequency map inside the window (Part 5 meets Part 7). The invariant breaks when the map has more than k keys.

Show Approach

Expand right, growing the map. While len(counts) > k, shrink from the left and delete keys that hit zero. Record the length once valid.

Show Solution
Python
def longest_at_most_k_distinct(s, k):
    counts = {}
    left = 0
    best = 0
    for right in range(len(s)):
        counts[s[right]] = counts.get(s[right], 0) + 1
        while len(counts) > k:            # too many distinct → shrink
            counts[s[left]] -= 1
            if counts[s[left]] == 0:
                del counts[s[left]]       # keep the key count honest
            left += 1
        best = max(best, right - left + 1)
    return best

print(longest_at_most_k_distinct("eceba", 2))    # 3

Fixed or Variable?

Fixed-size windowVariable-size window
Window sizegiven (k)discovered by the condition
Left pointerimplicit — right − kexplicit, moves only while invalid (or while still valid, for "shortest")
Loop shapeone for, one add + one remove per stepfor (expand) with an inner while (shrink)
Typical asks"max/avg of every k-block""longest / shortest window satisfying X"
TimeO(n)O(n) — each pointer walks forward once
Tip — when does left move, and why nothing is missed

For longest windows: left moves only to restore validity, minimally, so for every right you keep the largest valid window ending there. For shortest windows: left moves while the window is still valid, so every shorter window ending at that right gets measured before expanding resumes. In both cases right visits every index once and left only moves forward — so every possible window end is examined, and the pointers together do at most 2n steps.

What You Learned

  • A window is a contiguous block; "subarray/substring" hints window, "subsequence" forbids it.
  • Fixed windows slide with one add and one remove — the repeated re-summing disappears.
  • Variable windows expand right every step and shrink left only when the invariant demands it.
  • The invariant ("all chars distinct", "sum ≥ k") is the contract; the code merely enforces it.
  • State inside the window can be a sum, a set, or a frequency map — whatever the question measures.
  • Why nothing is missed: every index becomes a right end exactly once, and left never moves backward.

Pattern Recognition

Think "sliding window" when the question is about the longest, shortest, or best contiguous stretch satisfying a condition — and the condition survives sliding (adding/removing one edge element changes it predictably). Clues: subarray substring size k longest…without shortest…with sum at most K distinct. An O(n) or O(n·k) target on an O(n²) brute force is the second clue.

Complexity Summary

ProblemWindowState insideTimeSpace
Max sum subarray of size kfixedrunning sumO(n)O(1)
Longest substring without repeatsvariableset of charsO(n)O(alphabet)
Smallest subarray with sum ≥ kvariablerunning sumO(n)O(1)
At most K distinct charactersvariablefrequency mapO(n)O(k)

Common Mistakes

  • Building the first window inside the loop instead of before it (negative indices, half-built sums).
  • Using if where the shrink needs a while — one removal rarely clears the violation.
  • Recording the answer only after the loop instead of at the correct point (valid-and-growing → after shrink; still-valid-while-shrinking → inside the shrink loop).
  • Forgetting to delete map keys that drop to zero — len(counts) then lies about distinct counts.
  • Applying the sum-window template to arrays with negative numbers.
  • Writing right - left for an inclusive window and reporting lengths one too small.

Mini Quiz

Q1. A fixed window's sum is 20. It slides: element 6 enters on the right, element 2 leaves on the left. New sum?
20 + 6 − 2 = 24. One in, one out — that is the whole fixed-window mechanic.
Q2. In "longest substring without repeating characters", exactly what makes left move?
Only a duplicate inside the window breaks the invariant; left then shrinks until the previous copy of that character has left.
Q3. "Smallest subarray with sum ≥ k" maintains which state inside the window?
The condition is about the sum, so the sum is the state — one integer, updated by += and −=.
Q4. The variable-window loop has a while inside a for — why is it still O(n)?
Total work = total right moves + total left moves ≤ n + n = 2n. Nested-looking loops are O(n) when the inner pointer never restarts.
Q5. "Maximum average of any subarray of size k" is which kind of window?
The size is part of the input, so the window is fixed; the average rides on the same running sum as the maximum version.

Practice Problems

Practice — Minimum Sum Subarray of Size K

🟢 L1

Return the minimum sum of any k contiguous elements: [4, 2, 1, 7, 3], k = 2 → 3 (the window [2, 1]).

Show Hint

Identical skeleton to 7.1 — flip the comparison.

Show Approach

Fixed window, running sum, update with min instead of max.

Show Solution
Python
def min_sum_k(arr, k):
    window = sum(arr[:k])
    best = window
    for right in range(k, len(arr)):
        window += arr[right] - arr[right - k]
        best = min(best, window)
    return best

print(min_sum_k([4, 2, 1, 7, 3], 2))    # 3

Practice — Windows With Average ≥ Threshold

🟡 L2

Count how many size-k subarrays have an average ≥ threshold: [2,2,2,2,5,5,5,8], k = 3, threshold = 4 → 3.

Show Hint

average ≥ threshold is the same as sum ≥ threshold × k — avoid floating point entirely.

Show Approach

Fixed window of size k with a running sum; count the windows whose sum clears threshold * k.

Show Solution
Python
def count_windows(arr, k, threshold):
    target = threshold * k
    window = sum(arr[:k])
    count = 1 if window >= target else 0
    for right in range(k, len(arr)):
        window += arr[right] - arr[right - k]
        if window >= target:
            count += 1
    return count

print(count_windows([2, 2, 2, 2, 5, 5, 5, 8], 3, 4))    # 3
# windows: [2,2,2]=6, [2,2,5]=9, [2,5,5]=12 ✅, [5,5,5]=15 ✅, [5,5,8]=18 ✅

Practice — Find All Anagram Start Indices

🟡 L2

Return all start indices where an anagram of p begins in s: s = "cbaebabacd", p = "abc" → [0, 6].

Show Hint

Part 5's Counter + Part 7's fixed window. The window size is len(p); the state is the window's character counts.

Show Approach

Slide a fixed window of size len(p); maintain its Counter by adding the entering and removing the leaving character (deleting keys at zero); compare with Counter(p) at each step.

Show Solution
Python
from collections import Counter

def find_anagrams(s, p):
    n, m = len(s), len(p)
    result = []
    if n < m:
        return result
    p_count = Counter(p)
    window = Counter(s[:m])
    if window == p_count:
        result.append(0)
    for i in range(m, n):
        window[s[i]] += 1              # entering character
        window[s[i - m]] -= 1          # leaving character
        if window[s[i - m]] == 0:
            del window[s[i - m]]       # keep the Counter comparable
        if window == p_count:
            result.append(i - m + 1)
    return result

print(find_anagrams("cbaebabacd", "abc"))    # [0, 6]

Challenge Problem

Challenge — Max Consecutive Ones III (flip at most k zeroes)

🟠 L3 · Recognize

A binary array and an integer k: you may flip at most k zeroes to ones. Return the length of the longest contiguous run of ones achievable: [1,1,1,0,0,0,1,1,1,1,0], k = 2 → 6.

Show Hint 1

Rephrase: the longest window containing at most k zeroes. That is a variable-window condition you have already met.

Show Hint 2

What single integer is the state? Count the zeroes currently inside the window.

Show Approach

Expand right; when the zero count exceeds k, shrink from the left until it does not (dropping a zero decrements the count); record the length when the window is valid again.

Show Solution
Python
def longest_ones_with_k_flips(nums, k):
    left = 0
    zeros = 0                      # zeroes currently in the window
    best = 0
    for right in range(len(nums)):
        if nums[right] == 0:
            zeros += 1
        while zeros > k:           # too many zeroes → shrink
            if nums[left] == 0:
                zeros -= 1
            left += 1
        best = max(best, right - left + 1)
    return best

print(longest_ones_with_k_flips([1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0], 2))    # 6
Dry Run — k = 2 (zeros tracked per window)
r=0..2:  window [1,1,1]         zeros=0   best=3
r=3 (0):  window [1,1,1,0]      zeros=1   best=4
r=4 (0):  window [1,1,1,0,0]    zeros=2   best=5
r=5 (0):  zeros=3 > 2 → shrink: drop 1,1,1, then the 0 at l=3 → zeros=2, left=4
r=6..9:   window grows to indices 4..9 = [0, 0, 1, 1, 1, 1]  zeros=2
          best = 9-4+1 = 6
r=10 (0): zeros=3 → shrink past l=4 (a 0) → zeros=2, left=5   window len 6
answer: 6  — flip the two zeroes inside [0, 0, 1, 1, 1, 1]
Pattern Insight

"Longest window with at most k bad elements" is the same template as at-most-k-distinct: a counter of violations as state, shrink exactly when the counter exceeds k. Flip-the-zeroes, at-most-k-replacements, longest-subarray-with-at-most-k-odds — one skeleton, different "bad element" tests.

Revision Checklist

PART 8

Prefix Sum

Pay once, query forever — turning every range question into a subtraction.

Here is a deceptively simple situation: an array of numbers, and a stream of questions like "what is the sum of elements 20 through 84?". Answer honestly by adding them up, and every question costs up to n additions. Ten thousand questions later you have re-added the same elements millions of times. The prefix sum pays a one-time O(n) toll and turns every range sum into a single subtraction — and, with a dictionary alongside it (Part 5), it counts subarrays in ways sliding windows cannot.

The Cost of Repeated Range Sums

Concretely: an array of 10,000 daily step counts, and 1,000 questions of the form "total steps from day l to day r". Summing each range by hand costs up to 10,000 additions per question:

10,000,000
additions — re-summing each query
11,000
additions — prefix sum, build once + 1,000 subtractions
O(1)
cost per query after the build

The waste is structural: consecutive questions overlap ("days 20–84" and "days 30–90" share 55 days), yet the naive method re-adds the overlap every single time. Prefix sums keep every partial total so no overlap is ever added twice.

The Prefix Sum Idea

Concept

prefix[i] = the sum of the first i elements of the array. By convention prefix[0] = 0 — the sum of zero elements — which is the detail that makes every formula work cleanly. Then the sum of nums[l..r] (inclusive) is:

sum(l..r) = prefix[r + 1] − prefix[l] — a subtraction instead of a loop.

See it on real numbers — nums = [3, 1, 4, 1, 5, 9]:

30
11
42
13
54
95
nums — the original array, indices 0…5
00
31
42
83
94
145
236
prefix — prefix[i] = 3 + 1 + … up to (but not including) index i

Why the formula works: prefix[6] = 23 is "everything up to index 5", and prefix[2] = 4 is "everything before index 2". Their difference keeps exactly the slice between — indices 2 through 5: 4 + 1 + 5 + 9 = 19 = 23 − 4. The shared head of the two totals cancels.

Using prefix = [0, 3, 4, 8, 9, 14, 23] for nums = [3, 1, 4, 1, 5, 9], what is sum of indices 1…3?
Sum(1..3) = prefix[3+1] − prefix[1] = 9 − 3 = 6. Check by hand: 1 + 4 + 1 = 6 ✅. The right end is r + 1, the left end is l — exactly.

Building Prefix Sums

One pass, each entry reusing the previous one — that is the entire build:

Python
def build_prefix(nums):
    prefix = [0]                    # sum of zero elements
    for x in nums:
        prefix.append(prefix[-1] + x)
    return prefix

def range_sum(prefix, left, right):    # sum of nums[left..right], inclusive
    return prefix[right + 1] - prefix[left]
Dry Run — nums = [3, 1, 4, 1, 5, 9]
start:        prefix = [0]
after 3:      prefix = [0, 3]
after 1:      prefix = [0, 3, 4]
after 4:      prefix = [0, 3, 4, 8]
after 1:      prefix = [0, 3, 4, 8, 9]
after 5:      prefix = [0, 3, 4, 8, 9, 14]
after 9:      prefix = [0, 3, 4, 8, 9, 14, 23]

range_sum(1, 3) = prefix[4] - prefix[1] = 9 - 3 = 6   (1 + 4 + 1 ✅)
range_sum(2, 5) = prefix[6] - prefix[2] = 23 - 4 = 19 (4 + 1 + 5 + 9 ✅)
Common Mistake — the off-by-one

The formula is prefix[r + 1] − prefix[l], not prefix[r] − prefix[l]. Since prefix[i] counts elements before index i, stopping at prefix[r] would silently drop nums[r] from every query. When a range-sum answer is off by exactly the last element, this is why.

Worked Problems

8.1 Range Sum Query

🟡 L2 · Apply
Problem

Design a structure built from an array that answers many queries: "sum of nums[left..right]" — each answered instantly. The array may contain negative numbers.

Input / Output

nums = [-2, 0, 3, -5, 2, -1] → sum_range(0, 2) = 1, sum_range(2, 5) = -1

Why Is This a Prefix-Sum Problem?
Pattern Insight

Many unpredictable range questions over the same array — the exact job prefix sums were invented for. There is no condition to maintain live (that would be a window); the array is static, so pay once at construction and answer with math forever after.

Brute Force

Sum the slice on every query — O(right − left) each. Overlapping queries re-add the same elements again and again; that repeated summation is what disappears.

Optimal Idea

Build the prefix array once in O(n); every query becomes one subtraction.

Python Solution
Python
class RangeSumNaive:
    def __init__(self, nums):
        self.nums = nums

    def sum_range(self, left, right):
        total = 0
        for i in range(left, right + 1):   # re-adds the overlap every time
            total += self.nums[i]
        return total
Python
class RangeSum:
    def __init__(self, nums):
        self.prefix = [0]
        for x in nums:
            self.prefix.append(self.prefix[-1] + x)

    def sum_range(self, left, right):
        return self.prefix[right + 1] - self.prefix[left]
Dry Run
Dry Run — nums = [-2, 0, 3, -5, 2, -1]
prefix = [0, -2, -2, 1, -4, -2, -3]

sum_range(0, 2) = prefix[3] - prefix[0] = 1 - 0     = 1
                  check: -2 + 0 + 3 = 1 ✅
sum_range(2, 5) = prefix[6] - prefix[2] = -3 - (-2) = -1
                  check: 3 + (-5) + 2 + (-1) = -1 ✅

negatives are no problem — prefix sums are just arithmetic
Complexity

Build O(n) time, O(n) space · each query O(1). With Q queries: O(n + Q) total instead of O(n · Q).

Edge Cases
  • Single-element range: sum_range(3, 3) = prefix[4] − prefix[3] — exactly nums[3].
  • Full-range query: sum_range(0, n-1) = prefix[n].
  • Negative values: subtraction handles them naturally, as the dry run shows.
Common Mistakes
  • Using prefix[r] − prefix[l] — drops the last element of the range.
  • Forgetting the leading 0 in the prefix array — then ranges starting at index 0 need a special case.
  • Rebuilding the prefix after each query — the build is one-time by design.
Pattern Recognition
Pattern Insight

many range sum queries + static array ⇒ build a prefix sum array once. "Sum of range l…r" answered in O(1) is the fingerprint of this pattern.

8.2 Running Sum

🟢 L1 · Understand
Problem

Given an array, return the running sum: element i of the output is the total of nums[0..i].

Input / Output

[1, 2, 3, 4] → [1, 3, 6, 10]

Key Observation

A running sum is the prefix sum, shifted by one: running[i] = prefix[i + 1]. Where 8.1 stored prefixes to answer queries, this problem simply outputs them.

Brute Force

For each i, sum nums[0..i] from scratch — O(n²). Element 0 gets re-added n times; the repeated work is re-summing the head of the array.

Optimal Idea

Carry one accumulator; each output is the accumulator after adding the current element.

Python Solution
Python
def running_sum(nums):
    result = []
    total = 0
    for x in nums:
        total += x
        result.append(total)
    return result

print(running_sum([1, 2, 3, 4]))    # [1, 3, 6, 10]
Dry Run
Dry Run — nums = [1, 2, 3, 4]
x = 1 → total = 1  → result [1]
x = 2 → total = 3  → result [1, 3]
x = 3 → total = 6  → result [1, 3, 6]
x = 4 → total = 10 → result [1, 3, 6, 10]
Complexity

Time O(n) · Space O(n) for the output (O(1) beyond it).

Edge Cases
  • Empty array → empty output.
  • Negatives: [2, -1, 3] → [2, 1, 4] — nothing special is needed.
Common Mistakes
  • Appending nums[i] instead of the accumulator.
  • Recomputing sum(nums[:i+1]) inside the loop — the O(n²) trap this problem targets.
Pattern Recognition
Pattern Insight

"Up to this point" totals ⇒ a running accumulator. Whenever an output element depends on everything so far, ask whether one carried variable replaces a nested sum.

8.3 Equilibrium / Pivot Index

🟡 L2 · Apply
Problem

Find the index where the sum of all elements strictly left equals the sum of all elements strictly right. Return the leftmost such index, or −1.

Input / Output

[1, 7, 3, 6, 5, 6] → 3 (left 1+7+3 = 11, right 5+6 = 11)

Key Observation

You never need two running sums. Keep the total and a running left sum; then right = total − left − nums[i]. One equation replaces re-summing both sides — the pivot condition becomes a simple comparison.

Brute Force

For each index, sum the left side and sum the right side — O(n²). Every index re-adds almost the entire array twice.

Optimal Idea

One pass with total and left. At each i: compute right by subtraction, compare, then absorb nums[i] into left.

Python Solution
Python
def pivot_index(nums):
    total = sum(nums)
    left = 0
    for i, x in enumerate(nums):
        right = total - left - x     # everything except left side and x
        if left == right:
            return i
        left += x                    # x joins the left side for the next index
    return -1
Dry Run
Dry Run — nums = [1, 7, 3, 6, 5, 6], total = 28
i=0  x=1: right = 28 - 0  - 1 = 27   left = 0   → no    left → 1
i=1  x=7: right = 28 - 1  - 7 = 20   left = 1   → no    left → 8
i=2  x=3: right = 28 - 8  - 3 = 17   left = 8   → no    left → 11
i=3  x=6: right = 28 - 11 - 6 = 11   left = 11  → 11 == 11 ✅  return 3
Complexity

Time O(n) · Space O(1) — no prefix array even needed, just two numbers.

Edge Cases
  • Pivot at index 0: left side is empty (0). [2, 1, -1] → i=0: right = 2−0−2 = 0 = left → returns 0.
  • Pivot at the last index: right side is empty (0) — the loop naturally allows it.
  • Single element [1] → 0 (both sides empty).
Common Mistakes
  • Starting the loop with left = nums[0] — at i = 0 the left side must be empty.
  • Forgetting to subtract nums[i] itself when computing the right side.
  • Updating left before the comparison — the pivot element belongs to neither side.
Pattern Recognition
Pattern Insight

"Left of i vs right of i" questions ⇒ total + running left sum, right by subtraction. Variants: left/right sum differences, "can we split into two equal parts", product versions — same skeleton.

Prefix Frequency — Counting Subarrays With Sum K

The deepest idea in this part: prefixes turned backward. Take any two positions j < i in the array. The sum of the elements between them is prefix[i] − prefix[j]. So a subarray with sum exactly k ending at position i exists precisely when some earlier prefix equals prefix[i] − k. Count the earlier prefixes — with a dictionary, naturally.

Concept — prefixes that appear twice

If the running total takes the same value at two different moments, the chunk between those moments sums to 0. More generally: the difference of two prefixes is the sum of the subarray between them. Counting subarrays with sum k = counting, at each position, how many earlier prefixes hit current − k.

Python
def count_subarrays_sum_k(nums, k):
    seen = {0: 1}              # prefix sum 0 occurred once: before anything
    cur = 0
    count = 0
    for x in nums:
        cur += x
        need = cur - k         # a start prefix that makes a k-subarray end here
        if need in seen:
            count += seen[need]
        seen[cur] = seen.get(cur, 0) + 1
    return count

print(count_subarrays_sum_k([1, 1, 1], 2))    # 2
Dry Run — nums = [1, 1, 1], k = 2
seen = {0: 1}, cur = 0, count = 0

x = 1: cur = 1   need = 1-2 = -1  → not in seen              seen = {0:1, 1:1}
x = 1: cur = 2   need = 2-2 =  0  → seen[0] = 1 → count = 1  seen = {0:1, 1:1, 2:1}
x = 1: cur = 3   need = 3-2 =  1  → seen[1] = 1 → count = 2  seen = {0:1, 1:1, 2:1, 3:1}

answer: 2 — the two subarrays [1, 1] starting at index 0 and index 1 ✅
Why the map starts as {0: 1}

A subarray that begins at index 0 has "no elements before it" — its starting prefix is 0. Seeding seen with {0: 1} counts that starting line. Without it, the very first subarray of every array would be invisible to the algorithm.

In count-subarrays-with-sum-k, why is the map initialized to {0: 1}?
"Sum so far − k = 0" marks a subarray that runs from the very beginning. The seed entry represents the empty prefix before the array starts.
⚠️ Where sliding window gives up and prefix sums do not

Counting subarrays with sum k on an array with negative numbers defeats the sliding window: a bigger window can have a smaller sum, so neither pointer can commit to a direction. The prefix + dictionary method does not care about signs — it is pure arithmetic. This is the strongest reason to know both tools rather than one.

Prefix Sum vs Sliding Window

Both patterns answer questions about ranges, and beginners mix them up. The honest split:

Sliding Window (Part 7)Prefix Sum (this part)
Answersbest/longest/shortest live window under a conditionarbitrary range sums; counting subarrays by sum
How it movesleft and right sweep forward, window updated in placeone pass builds totals; queries are subtraction
State keptcurrent window's sum / set / Counterthe prefix array (or a dictionary of prefixes)
Negative numbers"shrink while valid" breaksworks fine — arithmetic is sign-agnostic
Fixed size knatural fitalso possible, but a window is simpler
Typical clue"longest/shortest contiguous…""sum of range l…r" (many queries), "count subarrays with sum…"

Quick Drills

Drill — Count Pivot Positions

🟡 L2

Count all indices whose left sum equals their right sum: [2, 0, 2] → 1 (index 1). Edge case: [0, 0, 0] → 3 — every index qualifies.

Show Hint

8.3 returned on the first pivot; remove the early return and count instead.

Show Solution
Python
def count_pivots(nums):
    total = sum(nums)
    left = 0
    count = 0
    for x in nums:
        right = total - left - x
        if left == right:
            count += 1
        left += x
    return count

print(count_pivots([2, 0, 2]))    # 1
print(count_pivots([0, 0, 0]))    # 3

Drill — Count Subarrays With Sum K (introduction)

🟠 L3

[1, 2, 3], k = 3 → 2 (the subarrays [1, 2] and [3]). Trace the prefix dictionary by hand before coding.

Show Hint

At each step the running total is 1, 3, 6. Which earlier prefixes equal current − 3?

Show Solution
Python
def count_subarrays_sum_k(nums, k):
    seen = {0: 1}
    cur = 0
    count = 0
    for x in nums:
        cur += x
        count += seen.get(cur - k, 0)
        seen[cur] = seen.get(cur, 0) + 1
    return count

print(count_subarrays_sum_k([1, 2, 3], 3))    # 2
Trace
cur=1: need -2 → 0 hits        seen {0:1, 1:1}
cur=3: need  0 → 1 hit → count 1   seen {0:1, 1:1, 3:1}
cur=6: need  3 → 1 hit → count 2   seen {0:1, 1:1, 3:1, 6:1}

Drill — Does a Subarray With Sum K Exist?

🟡 L2

Answer a single yes/no: is there any contiguous subarray summing to k? [1, 2, 3], k = 5 → True ([2, 3]); [1, 2], k = 0 → False.

Show Hint

The counting version with a set instead of a counter dictionary — the moment cur − k is an old prefix, stop.

Show Solution
Python
def subarray_sum_exists(nums, k):
    seen = {0}                 # empty prefix before the array
    cur = 0
    for x in nums:
        cur += x
        if cur - k in seen:
            return True
        seen.add(cur)
    return False

print(subarray_sum_exists([1, 2, 3], 5))    # True   ([2, 3])
print(subarray_sum_exists([1, 2], 0))       # False

Exists → a Seen Set (Part 5) over prefixes; count how many → a dictionary of prefix counts. Same math, different bookkeeping.

What You Learned

  • Why re-summing ranges is O(n) per query and what prefix sums do to that: O(n) once, O(1) per query.
  • That prefix[i] is the sum of the first i elements, with prefix[0] = 0 making the formula universal.
  • The range formula sum(l..r) = prefix[r+1] − prefix[l] — and the classic off-by-one when the +1 is forgotten.
  • That total + running left sum replaces two running sums (pivot index).
  • The prefix-frequency idea: subarrays with sum k are differences of prefixes, counted with a dictionary seeded with {0: 1}.
  • When to choose prefix sums over a sliding window — especially with negative numbers.

Pattern Recognition

Think "prefix sum" when the problem involves many queries over fixed ranges ("sum of l…r"), "up to index i" totals (running sum, pivot, left/right balance), or counting subarrays by their sum — including with negatives. Clues: range sum running total pivot / equilibrium count subarrays with sum k. If the question instead hunts a best window under a live condition, go back to Part 7.

Complexity Summary

TaskTimeExtra Space
Build prefix arrayO(n)O(n)
One range-sum query after the buildO(1)—
Running sumO(n)O(n) for the output
Pivot indexO(n)O(1)
Count subarrays with sum kO(n) averageO(n) for the prefix map

Common Mistakes

  • Querying prefix[r] − prefix[l] instead of prefix[r+1] − prefix[l] — the last element of the range silently vanishes.
  • Omitting the leading 0 in the prefix array, then special-casing ranges that start at index 0.
  • Updating the prefix dictionary before checking cur − k — the count-0 case breaks.
  • Forgetting {0: 1} / {0} seeding — subarrays starting at index 0 go uncounted.
  • Comparing left after already adding nums[i] to it in the pivot loop.
  • Reaching for a sliding window on arrays containing negatives where only prefix methods are safe.

Mini Quiz

Q1. prefix[i] is best described as:
prefix[i] covers indices 0 … i−1. That half-open convention is what makes sum(l..r) = prefix[r+1] − prefix[l] come out right.
Q2. The sum of nums[l..r] (inclusive) equals:
Take the total through r (prefix[r+1]) and remove the total before l (prefix[l]).
Q3. Why does the prefix array start with a 0?
sum(0..r) = prefix[r+1] − prefix[0] = prefix[r+1] − 0. The empty prefix makes the edge case disappear.
Q4. When counting subarrays with sum k, the dictionary stores:
Each occurrence of prefix value (cur − k) marks one subarray ending here — so we count prefix occurrences, not subarrays directly.
Q5. The array contains negative numbers and you need the smallest subarray with sum ≥ k. Which tool is safe?
The window's monotonicity argument needs positives. Prefix sums and dictionaries keep working regardless of sign.

Practice Problems

Practice — Find the Highest Altitude

🟢 L1

A biker starts at altitude 0; gain[i] is the net change of leg i. Return the highest altitude reached: gain = [-5, 1, 5, 0, -7] → 1.

Show Hint

Altitudes are the running sum of gains — 8.2 with a max tracker.

Show Approach

Accumulate the gains, track the maximum of the accumulator (including the starting altitude 0).

Show Solution
Python
def highest_altitude(gain):
    altitude = 0
    best = 0                     # starting altitude counts
    for g in gain:
        altitude += g
        best = max(best, altitude)
    return best

print(highest_altitude([-5, 1, 5, 0, -7]))    # 1
# altitudes: 0, -5, -4, 1, 1, -6

Practice — Left and Right Sum Differences

🟡 L2

For each index i return |left_sum − right_sum|, excluding nums[i] from both: [10, 4, 8, 3] → [15, 1, 11, 22].

Show Hint

One total and one running left sum — the 8.3 skeleton with an output list instead of an early return.

Show Approach

Compute total. At each i: right = total − left − nums[i]; append abs(left − right); add nums[i] to left.

Show Solution
Python
def left_right_differences(nums):
    total = sum(nums)
    left = 0
    result = []
    for x in nums:
        right = total - left - x
        result.append(abs(left - right))
        left += x
    return result

print(left_right_differences([10, 4, 8, 3]))    # [15, 1, 11, 22]

Practice — Split Into Two Equal-Sum Parts?

🟡 L2

Can the array be cut into two non-empty parts with equal sums? [2, 1, 1, 2] → True ([2, 1] | [1, 2]); [1, 2] → False.

Show Hint

A valid cut after index i means the prefix through i equals exactly half the total.

Show Approach

If the total is odd → immediately False. Otherwise walk a running sum over all but the last index and compare with total // 2.

Show Solution
Python
def can_split_equal(nums):
    total = sum(nums)
    if total % 2 != 0:
        return False
    left = 0
    for i in range(len(nums) - 1):   # both parts must be non-empty
        left += nums[i]
        if left == total // 2:
            return True
    return False

print(can_split_equal([2, 1, 1, 2]))    # True
print(can_split_equal([1, 2]))          # False

Challenge Problem

Challenge — Product of Array Except Self (no division)

🟠 L3 · Recognize

Return an array where output[i] is the product of every element except nums[i] — without using division: [1, 2, 3, 4] → [24, 12, 8, 6].

Show Hint 1

The product "except self" is (everything left of i) × (everything right of i). Sound familiar? It is 8.3 with multiplication.

Show Hint 2

One pass can fill the left products; where does the right product come from without a second array?

Show Approach

First pass left→right: result[i] = product of all elements before i (a running prefix product). Second pass right→left: multiply each result[i] by a running suffix product. Two sweeps, O(n), O(1) extra beyond the output.

Show Solution
Python
def product_except_self(nums):
    n = len(nums)
    result = [1] * n

    left = 1                       # product of everything before i
    for i in range(n):
        result[i] = left
        left *= nums[i]

    right = 1                      # product of everything after i
    for i in range(n - 1, -1, -1):
        result[i] *= right
        right *= nums[i]

    return result

print(product_except_self([1, 2, 3, 4]))    # [24, 12, 8, 6]
Dry Run — nums = [1, 2, 3, 4]
after left pass:  result = [1, 1, 2, 6]   (prefix products)
right pass: i=3: ×1 → 6   i=2: ×4 → 8   i=1: ×12 → 12   i=0: ×24 → 24
final: [24, 12, 8, 6] ✅
Pattern Insight

Prefix thinking is not only for sums: any "everything before × everything after" structure — prefix products, prefix maximums, prefix counts — falls to the same two-sweep pattern. Recognize the shape, then choose the operation.

Revision Checklist

PART 9

Sorting

One line of Python turns chaos into structure — and structure is where patterns live.

Sorting is the most useful preprocessing step in problem solving. Sorting an array doesn't add or remove any information — but it changes what you can see: equal values become neighbours, the smallest pair of values sits at the front, and "find something" often turns into "scan once". In this part you'll master Python's built-in sorting, peek inside three classical sorts to understand what sorting really costs, and solve problems where sorting is the key move.

Concept — sorting makes structure visible

The data is the same. What changes is the relationships between neighbours. Unsorted, "are there duplicates?" means checking every pair. Sorted, it means one adjacent comparison. That is the whole point of sorting as a problem-solving tool: it converts expensive questions into cheap neighbour checks.

Unsorted — nothing to see
70
21
92
23
54
Are there duplicates? You'd have to compare every pair — O(n²).
Sorted — structure appears
20
21
52
73
94
Equal values are now neighbours — one adjacent scan finds duplicates.

Python's built-in sorting — the tool you'll actually use

In real problems you will always use Python's built-in sort. It is implemented in C, it is stable, and its worst case is O(n log n). Your job is not to rebuild it — your job is to control what order it produces.

sorted() vs list.sort()

Two functions, one job — the difference is what happens to the original:

Python
nums = [7, 2, 9, 2, 5]

a = sorted(nums)     # returns a NEW sorted list; nums unchanged
print(a)             # [2, 2, 5, 7, 9]
print(nums)          # [7, 2, 9, 2, 5]  ← original intact

nums.sort()          # sorts nums IN PLACE; returns None
print(nums)          # [2, 2, 5, 7, 9]

word_list = "banana apple cherry".split()
chars = sorted("dcba")            # works on any iterable → ['a', 'b', 'c', 'd']
lengths = sorted(word_list, key=len)  # sorted by string length
sorted(iterable)list.sort()
Returnsa new sorted listNone
Originalunchangedoverwritten in place
Works onany iterable (list, string, tuple, dict…)lists only
Use whenyou still need the original orderyou don't need the original, and want O(1) extra space
Common Mistake

nums = nums.sort() does not give you a sorted list — .sort() returns None, so nums becomes None and the next line crashes. Either call nums.sort() alone, or use nums = sorted(nums). This single bug causes a huge share of beginner runtime errors.

Ascending, descending, and reverse=True

Python
nums = [4, 1, 7, 3]

print(sorted(nums))                 # [1, 3, 4, 7]   ascending (default)
print(sorted(nums, reverse=True))   # [7, 4, 3, 1]   descending
nums.sort(reverse=True)             # same, in place

key= — sort by a rule, not by raw value

The key parameter is where sorting becomes powerful. Give it a function; Python calls it once per element and sorts by the returned value, while the list still contains the original elements:

Python
words = ["banana", "Apple", "cherry", "fig"]

sorted(words)                    # ['Apple', 'banana', 'cherry', 'fig']
                                 # (uppercase letters sort before lowercase!)

sorted(words, key=str.lower)     # same order here, but ONLY because lowercase
                                 # alphabetical matches; try ["banana", "Cherry"]!

sorted(words, key=len)           # ['fig', 'Apple', 'banana', 'cherry']
                                 # banana/cherry tie at 6 → original order kept

nums = [-4, 2, -7, 1]
sorted(nums, key=abs)            # [1, 2, -4, -7]  → by |value|: 1, 2, 4, 7
Tip — how to think about key

Ask: "If I lined these elements up by some computed number, the order I want would appear?" By length → key=len. By distance from zero → key=abs. By last character → key=lambda w: w[-1]. The key function is your way of pointing at the part of each element that matters.

Custom ordering — tuples as keys

What if one rule isn't enough — "sort by length, and break ties alphabetically"? Compare tuples: Python compares them element by element, so a tuple works as a multi-criteria key:

Python
words = ["pear", "fig", "kiwi", "apple", "plum", "date"]

# primary: length ascending, secondary: alphabetical
print(sorted(words, key=lambda w: (len(w), w)))
# ['fig', 'date', 'kiwi', 'pear', 'plum', 'apple']

students = [("Ana", 82), ("Ben", 91), ("Cal", 82)]

# primary: marks descending (negate it), secondary: name ascending
print(sorted(students, key=lambda s: (-s[1], s[0])))
# [('Ben', 91), ('Ana', 82), ('Cal', 82)]

Two tricks live in this snippet:

  • Ascending + descending in one key: for numbers, negate the descending criterion (-s[1]). Python has no "sort this column backwards" flag inside a tuple key.
  • String ties: for strings you can't negate — so keep the second criterion ascending, or sort twice (see the drill at the end of this part).
Concept — stable sorting

When two elements have equal keys, Python keeps them in their original relative order. This is called a stable sort, and it lets you layer sorts: sort by name first, then sort by marks — ties in marks stay alphabetised. You rarely need this trick, but it explains why equal keys never get "shuffled".

Under the hood, sorted() uses Timsort — a hybrid of merge sort and insertion sort, tuned for real-world data. You will never implement it; you will always call it.


Three classical sorts — for understanding, not for use

You will never submit bubble, selection, or insertion sort to a judge — Timsort beats all of them. So why learn them? Two reasons: they show what sorting actually does mechanically (compare and move), and they are the classic training ground for writing careful loops. Read each one for the idea, trace it once, and move on.

Bubble Sort — big values bubble to the end

Intuition: walk the array left to right, comparing neighbours. If the left one is bigger, swap them. After one full pass, the largest value has "bubbled" all the way to the last position — it's locked in. Repeat for the rest.

50
31
82
13
start — [5, 3, 8, 1]
30
51
12
83
after pass 1 — 5 and 3 swapped, 8 and 1 swapped → [3, 5, 1, 8]; the 8 is now in its final place
10
31
52
83
after pass 2 → [3, 1, 5, 8], after pass 3 → [1, 3, 5, 8] — sorted
Python
def bubble_sort(nums):
    n = len(nums)
    for pass_no in range(n - 1):
        for i in range(n - 1 - pass_no):     # tail is already sorted
            if nums[i] > nums[i + 1]:
                nums[i], nums[i + 1] = nums[i + 1], nums[i]
    return nums

Cost: ~n²/2 comparisons → O(n²) time, O(1) space. Why it's slow: it fixes one element per full pass, and it moves values only one step at a time — an element at the wrong end of the array needs n steps to reach the other end.

Selection Sort — select the minimum, put it in front

Intuition: find the smallest value in the whole array, swap it into position 0. Then find the smallest of the remainder, swap into position 1. The front of the array grows as a sorted zone; the back is the unsorted leftover.

50
31
82
13
start — minimum of the whole array is 1 (index 3)
10
31
82
53
swap 1 into index 0 → [1, 3, 8, 5]; next: min of {3, 8, 5} is 3 → stays; then min of {8, 5} is 5
10
31
52
83
swap 5 into index 2 → [1, 3, 5, 8] — sorted
Python
def selection_sort(nums):
    n = len(nums)
    for i in range(n - 1):
        min_idx = i
        for j in range(i + 1, n):        # find the min of the unsorted tail
            if nums[j] < nums[min_idx]:
                min_idx = j
        nums[i], nums[min_idx] = nums[min_idx], nums[i]
    return nums

Cost: it scans the whole tail at every step even if it's already sorted → always O(n²). Why it's slow: it never exploits existing order, and it can't stop early.

Insertion Sort — like sorting playing cards in your hand

Intuition: take elements one by one from the left; each new element slides left past bigger values until it lands in its correct spot inside the already-sorted prefix. This is how most people sort a hand of cards.

50
31
82
13
start — prefix [5] counts as sorted; take 3 and slide it before 5
30
51
82
13
[3, 5, 8, 1] — 8 already fits; take 1 and slide it all the way to the front
10
31
52
83
[1, 3, 5, 8] — sorted
Python
def insertion_sort(nums):
    for i in range(1, len(nums)):
        key = nums[i]                    # the card we're placing
        j = i - 1
        while j >= 0 and nums[j] > key:  # slide bigger cards right
            nums[j + 1] = nums[j]
            j -= 1
        nums[j + 1] = key                # drop it into its slot
    return nums

Cost: worst case O(n²), but if the array is already nearly sorted, each element slides at most one step → O(n) best case. That "fast on nearly-sorted data" property is exactly why real Timsort uses insertion sort internally on small chunks.

Head-to-head comparison

SortBestAverageWorstSpaceStable?When to use
BubbleO(n)*O(n²)O(n²)O(1)yesnever (teaching only)
SelectionO(n²)O(n²)O(n²)O(1)nonever (teaching only)
InsertionO(n)O(n²)O(n²)O(1)yestiny or nearly-sorted inputs
Timsort (Python's sorted)O(n)O(n log n)O(n log n)O(n)yeseverything real

* Bubble's O(n) best case needs an "if nothing swapped, stop early" check — without it, bubble is O(n²) even on sorted input.

O(n)
O(n log n)
O(n²)

Growth of work at n = 100,000: linear ~ 100k steps, n log n ~ 1.7M, n² ~ 10 billion. This is why "just use sorted()".

Common Mistake

Implementing bubble/selection/insertion sort in a submission "because sorting is the point of the problem". It isn't. The problem wants a result; nums.sort() gives you a correct, optimally fast sort in one line. Write the classical sorts only when the task explicitly asks you to.


Why sorting unlocks problems

Sorting is rarely the final answer — it's the move that makes the final answer easy. Three pairings cover a huge fraction of problems:

Sorting + two pointers

Sorted order lets you shrink a search from both ends: sum too small → advance the left pointer; too big → retreat the right one. Order is what makes the pointer directions meaningful. See Pair Sum below.

Sorting + greedy

"Take the smallest first" and "keep the interval that ends earliest" are only valid moves after sorting puts candidates in priority order. Greedy proofs almost always start with a sort.

Sorting + intervals

Meetings and bookings become mergeable blocks once sorted by start — Part 20 (Interval Problems) rests entirely on this one preprocessing step.

Unsorted input
→
sort (O(n log n))
→
structure appears
→
cheap scan O(n)
Pattern Insight — when to reach for sorting

Clues in the statement: "duplicates" → sorted puts them adjacent. "closest / smallest pair" → sorted puts candidates adjacent. "top k" → sorted gives you the extremes instantly. "ranges, meetings, bookings" → sorted intervals scan linearly. "binary search" → needs sorted input (next part). If any of these words appear and order doesn't matter to the answer, sort first and ask questions later.


Worked problems

9.1 Find the Second Largest

🟡 L2 · Apply
Problem

Given a list of numbers, return the second largest distinct value. If it doesn't exist (fewer than 2 distinct values), return None.

Input / Output

[4, 9, 2, 9, 7] → 7  ·  [5, 5] → None  ·  [1, 2] → 1

Key Observation

The duplicate 9 is the trap: "second largest" means second largest distinct value, not nums[-2] of the sorted array. Watch what each approach does with [..., 9, 9, ...].

Brute Force

Sort descending, then walk from the front skipping values equal to the maximum. O(n log n), and easy to get right:

Python
def second_largest_sorted(nums):
    if len(nums) < 2:
        return None
    s = sorted(set(nums), reverse=True)   # distinct values, big → small
    return s[1] if len(s) >= 2 else None
Optimal Idea

We only need the top two — sorting every element into perfect order is wasted work. Walk the list once, carrying two trackers: the best value seen, and the best value below the best. When a new champion arrives, the old champion is demoted to second place.

Sort the distinct values and pick index 1. Clean, uses set() to kill duplicates. Time O(n log n), space O(n). Perfectly acceptable — unless the problem demands O(n) or one pass.

Python
def second_largest(nums):
    best = None
    second = None
    for x in nums:
        if best is None or x > best:
            second = best        # old champion is demoted
            best = x
        elif x != best and (second is None or x > second):
            second = x           # better runner-up, still below best
    return second

The x != best check is what makes [9, 9] return None instead of 9 — a repeat champion is not a new value.

Dry Run — nums = [4, 9, 2, 9, 7] (one-pass version)
x = 4 → best is None        → best = 4,  second = None
x = 9 → 9 > 4               → second = 4, best = 9
x = 2 → 2 < 9, but 2 > 4? no → no change
x = 9 → 9 > 9? no · 9 == best? yes → skip (duplicate champion)
x = 7 → 7 < 9 and 7 > 4     → second = 7
answer: 7 ✅
Complexity

Sorting version: Time O(n log n) · Space O(n). One-pass version: Time O(n) · Space O(1).

Edge Cases
  • All values equal ([5, 5, 5]) → None.
  • Negative numbers: [-1, -5] → -5. The None initialisers (not 0!) handle this.
  • Empty or single-element list → None.
Common Mistakes
  • Returning sorted(nums)[-2] — wrong whenever the maximum repeats.
  • Initialising best = 0 — breaks on all-negative input. Use None.
Pattern Recognition
Pattern Insight

"Largest / second largest / top-k few" → before sorting everything, ask if a single pass with k trackers is enough. Sorting spends n log n ordering elements whose order you'll never look at.

9.2 Find All Duplicates (after sorting)

🟡 L2 · Apply
Problem

Return every value that appears two or more times in the list (each duplicate reported once), in any order.

Input / Output

[3, 1, 3, 5, 1] → [1, 3]  ·  [1, 2, 3] → []

Key Observation

Unsorted, duplicates can be anywhere. Sorted, they are adjacent — so "find duplicates" collapses into "compare each element with its left neighbour". This is the structure-visible idea from the start of this part, applied directly.

Brute Force

For each element, scan the rest of the list for a match — O(n²). Or count with a dictionary — O(n) time but O(n) extra space. Sorting gives a middle path: O(n log n) time, O(1) extra space (if we sort in place and don't count the output).

Optimal Idea

Sort, then one adjacent scan. Keep the last value added to the result so a value appearing three times isn't reported twice.

Python Solution
Python
def find_duplicates(nums):
    nums.sort()                    # duplicates are now neighbours
    dups = []
    for i in range(1, len(nums)):
        if nums[i] == nums[i - 1] and (not dups or dups[-1] != nums[i]):
            dups.append(nums[i])
    return dups
Dry Run
Dry Run — nums = [3, 1, 3, 5, 1]
sorted → [1, 1, 3, 3, 5]
i = 1 → 1 == 1 and dups empty      → append 1 → dups = [1]
i = 2 → 1 == 3? no
i = 3 → 3 == 3 and dups[-1]=1 != 3  → append 3 → dups = [1, 3]
i = 4 → 3 == 5? no
answer: [1, 3] ✅
Complexity

Time O(n log n) (the sort dominates; the scan is O(n)) · Space O(1) extra beyond the output.

Edge Cases
  • No duplicates → empty list.
  • Value appears 3+ times → reported exactly once (thanks to the dups[-1] check).
Common Mistakes
  • Forgetting the last-added check → [1,1,1,1] reports [1, 1, 1].
  • Comparing nums[i] with nums[i+1] and running off the end — compare backwards (i-1) instead.
Pattern Recognition
Pattern Insight

"Duplicates / missing / repeated element" + sorting allowed → sort and scan neighbours. The hash-set version wins when you can't reorder the data; the sorting version wins when extra memory is banned or you need sorted output anyway.

9.3 Pair Sum — two pointers on a sorted array

🟡 L2 · Apply
Problem

Given a list of numbers and a target, return a pair of two different elements whose sum is exactly target, or None.

Input / Output

nums = [7, 2, 11, 15, 3], target = 18 → (3, 15)

Brute Force

Try every pair — two nested loops, O(n²). For n = 100,000 that's ~5 billion comparisons. The repeated work: for each left element we rescan elements we've already ruled out.

Key Observation

Sort first: [2, 3, 7, 11, 15]. Now put one pointer at the smallest value and one at the largest. If the sum is too small, no left-side partner can help the smallest element — discard it (move left up). If too big, the largest is unpairable — discard it (move right down). Every step permanently eliminates one element: n steps total.

Optimal Idea

Sort O(n log n) + one shrinking scan O(n) = O(n log n). What disappeared: the second loop — "which partner matches this element?" is answered by position, not by searching.

Python Solution
Python
def pair_sum(nums, target):
    nums.sort()                        # order makes the two directions meaningful
    left, right = 0, len(nums) - 1
    while left < right:
        s = nums[left] + nums[right]
        if s == target:
            return (nums[left], nums[right])
        elif s < target:
            left += 1                  # sum too small → need a bigger left value
        else:
            right -= 1                 # sum too big → need a smaller right value
    return None
Dry Run
Dry Run — sorted [2, 3, 7, 11, 15], target = 18
left=0 (2),  right=4 (15) → 2+15 = 17 < 18 → move left →
left=1 (3),  right=4 (15) → 3+15 = 18 ✅ return (3, 15)
20L
31
72
113
154R
2 + 15 = 17 < 18 → the 2 can never reach the target → discard it, L moves right
20
31L
72
113
154R
3 + 15 = 18 ✅ found
Complexity

Time O(n log n) · Space O(1) (in-place sort). If you must return original indices, sort (value, index) pairs so the indices travel with their values.

Edge Cases
  • No valid pair → None (loop ends when left == right).
  • Duplicates like [3, 3] with target 6 → works: pointers sit on the two different copies.
Pattern Recognition
Pattern Insight

"Find a pair / triplet with some sum property" + sorting allowed → sort + two pointers converging from both ends. Each comparison kills one element, which is what turns O(n²) into O(n log n).


Quick drills

Drill — Sort Numbers, Both Directions

🟢 L1

Sort [4, 1, 7, 3] descending without changing the original, then descending in place.

Show Hint

One is a function you pass the list to; the other is a method you call on the list with a flag.

Show Solution
Python
nums = [4, 1, 7, 3]
desc_copy = sorted(nums, reverse=True)   # original untouched
nums.sort(reverse=True)                   # nums itself is now [7, 4, 3, 1]

Drill — Sort Words by Length, Then Alphabetically

🟡 L2

Sort ["pear", "fig", "kiwi", "apple", "plum", "date"] so shorter words come first and same-length words stay alphabetical.

Show Hint

You need two criteria. What Python type compares "first by A, then by B"?

Show Approach

Use a tuple key (len(w), w): Python compares lengths first and only falls back to the word itself on ties.

Show Solution
Python
words = ["pear", "fig", "kiwi", "apple", "plum", "date"]
print(sorted(words, key=lambda w: (len(w), w)))
# ['fig', 'date', 'kiwi', 'pear', 'plum', 'apple']

Drill — Merge Two Sorted Lists

🟡 L2

Merge [1, 4, 7] and [2, 3, 9] into one sorted list — without concatenating and re-sorting. (This "merge" is the beating heart of merge sort and of interval scanning in Part 20.)

Show Hint

Keep one finger on each list. Repeatedly take the smaller front element.

Show Solution
Python
def merge_sorted(a, b):
    i = j = 0
    out = []
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            out.append(a[i]); i += 1
        else:
            out.append(b[j]); j += 1
    out.extend(a[i:])      # one of these is empty
    out.extend(b[j:])
    return out

# merge_sorted([1, 4, 7], [2, 3, 9]) → [1, 2, 3, 4, 7, 9]

Drill — Sort 0s, 1s, 2s in One Pass (Dutch National Flag — intuition)

🟠 L2→L3

An array contains only 0s, 1s and 2s. Sort it in one pass. Full implementation knowledge is L3 — here, build the intuition gently.

Three pointers, three zones. Keep a low pointer (next slot for a 0), a mid pointer (the scanner), and a high pointer (next slot for a 2). Four regions exist at all times:

The picture to keep in your head
[ all 0s | all 1s | ??? unknown ??? | all 2s ]
  0..low-1  low..mid-1   mid..high     high+1..end

The scanner mid looks at one unknown cell at a time:

  • sees a 0 → swap it to the 0-zone edge (low), advance both low and mid. Safe to advance mid, because the value swapped in from low is a 1 that was already examined.
  • sees a 1 → it's already home; just advance mid.
  • sees a 2 → swap it to the 2-zone edge (high), pull high left — but do not advance mid, because the value swapped in from the back is unexamined.

Loop stops when mid > high — no unknown cells left, and the zones guarantee everything is sorted.

20L M
01
12
23
04H
start [2, 0, 1, 2, 0] — mid sees a 2 → swap with high (indexes 0 and 4), high moves left
00L M
01
12
23H
24
after the swap: [0, 0, 1, 2, 2], high now at 3 — the trailing 2s are settled
Dry Run — nums = [2, 0, 1, 2, 0]
low=0, mid=0, high=4
mid sees 2 → swap idx0 and idx4 → [0,0,1,2,2], high=3 (mid stays!)
mid sees 0 → swap idx0 with idx0 → low=1, mid=1
mid sees 0 → swap idx1 with idx1 → low=2, mid=2
mid sees 1 → mid=3
mid sees 2 → swap idx3 with idx3 → high=2 → now mid(3) > high(2) → stop
result: [0, 0, 1, 2, 2] ✅  (single pass)
Show the Code (optional — intuition first!)
Python
def sort_012(nums):
    low, mid, high = 0, 0, len(nums) - 1
    while mid <= high:
        if nums[mid] == 0:
            nums[low], nums[mid] = nums[mid], nums[low]
            low += 1
            mid += 1
        elif nums[mid] == 1:
            mid += 1
        else:                      # nums[mid] == 2
            nums[mid], nums[high] = nums[high], nums[mid]
            high -= 1              # mid does NOT advance — new value unexamined
    return nums
Pattern Insight

When a scan must partition a small set of categories in one pass, give each category a boundary pointer and swap values to their zone edges. The subtle rule: advancing the scanner is only safe when the value swapped in has already been examined.

What You Learned

  • Sorting doesn't change the data — it changes which questions are cheap. Duplicates, closest pairs and extremes all become neighbour checks.
  • sorted() returns a new list; .sort() edits in place and returns None.
  • key= sorts by a rule; tuple keys give multi-criteria order; negating a numeric key flips that criterion to descending.
  • Bubble, selection and insertion sort are O(n²) teaching tools; Python's Timsort is O(n log n) and is what you always use.
  • Sort + two pointers turns pair-searching from O(n²) into O(n log n) by discarding one element per step.
  • Three boundary pointers can partition 0/1/2 arrays in a single pass.

Pattern Recognition

Reach for sorting when the statement mentions duplicates, closest/smallest pairs, top-k, ranges/meetings, or when a "find" question can be answered by neighbours. Then ask the follow-up: does the sorted structure enable a two-pointer scan, a greedy pick, an interval sweep — or would a single pass with a few trackers beat the sort entirely? Sorting is the gateway to binary search (Part 10) and interval problems (Part 20).

Complexity Summary

OperationTimeExtra space
sorted(x) / x.sort() (Timsort)O(n log n)O(n) / O(1)
Bubble / Selection / Insertion (average)O(n²)O(1)
Adjacent scan of sorted data (duplicates)O(n) after the sortO(1)
Sort + two pointers (pair sum)O(n log n)O(1)
One-pass top-2 trackers (second largest)O(n)O(1)
Dutch flag three-pointer partitionO(n), one passO(1)

Common Mistakes

  • nums = nums.sort() → nums is now None.
  • sorted(nums)[-2] as "second largest" — breaks when the maximum repeats.
  • Initialising trackers to 0 instead of None — breaks on negative input.
  • Forgetting the tie-break inside a tuple key, or trying to negate a string key.
  • Reporting a triplicated value three times in the duplicates scan (track the last value added).
  • Advancing mid after swapping a 2 backwards in the Dutch flag — the swapped-in value is unexamined.

Mini Quiz

Q1. After nums = [3, 1, 2] and nums = nums.sort(), what is nums?
.sort() returns None — it mutates the list in place. Write either nums.sort() or nums = sorted(nums).
Q2. Which key sorts words by length first, then alphabetically for equal lengths?
Tuples compare element by element: length decides, and only on a tie does the word itself break it.
Q3. Which classical sort repeatedly swaps adjacent out-of-order neighbours?
Bubble compares nums[i] with nums[i+1] and swaps — that's what pushes the largest value to the end each pass. Selection scans for a minimum; insertion slides an element left.
Q4. Worst-case time of Python's built-in sorted() on n items?
Timsort is O(n log n) worst case (O(n) best case on already-sorted data) — always safe to assume n log n.

Practice Problems

P1 — Sort by Absolute Value

🟢 L1

Sort [-4, 2, -7, 1] by distance from zero → expect [1, 2, -4, -7].

Show Hint

Which computed number should decide the order?

Show Approach

Pass key=abs — sorting happens on |x|, but the list keeps the original values.

Show Solution
Python
nums = [-4, 2, -7, 1]
print(sorted(nums, key=abs))   # [1, 2, -4, -7]

P2 — Anagram Check via Sorting

🟢 L1

Return True if two strings are anagrams ("listen" / "silent" → True).

Show Hint

Anagrams contain exactly the same letters — so what looks identical once sorted?

Show Approach

Sorting both strings puts their letters in the same order iff they're anagrams. O(n log n) — a Counter would do O(n), but the sorting version is a two-liner.

Show Solution
Python
def is_anagram(s, t):
    return sorted(s) == sorted(t)

# is_anagram("listen", "silent") → True
# is_anagram("listen", "silentt") → False (different length, different sorted form)

P3 — Leaderboard Sort

🟡 L2

Given [("Ana", 82), ("Ben", 91), ("Cal", 82)], sort by marks descending, breaking ties by name ascending.

Show Hint

A tuple key handles two criteria — how do you make a numeric one go backwards?

Show Approach

key=lambda t: (-t[1], t[0]) — negate the marks for descending, keep the name as the ascending tie-break.

Show Solution
Python
students = [("Ana", 82), ("Ben", 91), ("Cal", 82)]
print(sorted(students, key=lambda t: (-t[1], t[0])))
# [('Ben', 91), ('Ana', 82), ('Cal', 82)]

Challenge Problem

Challenge — Maximum Toys Within Budget

🟠 L3 · Recognize

You have budget k and toy prices prices. Buy as many toys as possible (any toys, count matters, not value). prices = [1, 12, 5, 111, 200, 1000, 10], k = 50 → 4 (buy 1+5+10+12 = 28).

Show Hint

To maximise the count, which toys should you prefer? What order makes that preference executable?

Show Approach

Sorting + greedy: buy cheapest-first; stop at the first toy you can't afford. If a cheaper toy doesn't fit, no more expensive one can. O(n log n).

Show Solution
Python
def max_toys(prices, k):
    prices.sort()
    count = 0
    for p in prices:
        if p > k:
            break            # everything after is even pricier
        k -= p
        count += 1
    return count

# max_toys([1, 12, 5, 111, 200, 1000, 10], 50) → 4

This is the "sorting + greedy" pairing in its purest form: the sort converts "pick a best subset" into "walk left to right and stop".

Revision Checklist

PART 10

Binary Search

Cut the search space in half, every single step — a million candidates reduced to one in about 20 questions.

Linear search checks elements one at a time. Binary search asks a much smarter question: "which half can I throw away?" Every comparison discards half of everything still possible, so 1,000 items need ~10 steps, 1,000,000 items need ~20, and a billion need ~30. That jump — n steps → log n steps — is one of the biggest wins in all of DSA, and the pattern behind it (a shrinking search space with a fixed invariant) shows up far beyond sorted arrays. This is a long part on purpose: the base template, its five most important variants, and "binary search on the answer" all share one mental model.

The idea — 20 questions with a sorted array

Think of the guessing game: "I'm thinking of a number from 1 to 100." The smart strategy is to guess the middle (50). "Higher." Now 51–100 remains. Guess 75. "Lower." 51–74. Every guess cuts the remaining range in half — 100 → 50 → 25 → 12 → 6 → 3 → 1: at most 7 guesses.

Binary search is exactly this, played on a sorted array. Sortedness is what makes the game legal: if the middle element is smaller than the target, then — because the array is sorted — everything to the left of the middle is smaller too. Half the array is proven irrelevant without looking at it.

Linear scan — O(n)
20
51
82
123
164
235
386
567
target 16 → checks 2, 5, 8, 12, 16 … five comparisons for eight elements
Binary search — O(log n)
20
51
82
123
164
235
386
567
only the middle is checked — 12 < 16 → the left half dies instantly
n (items)Linear scan, worst caseBinary search, worst case
10104
1,0001,00010
1,000,0001,000,00020
1,000,000,0001,000,000,00030
Concept — the search space

Stop thinking "I'm checking array elements". Think: there is a range of candidate positions [lo..hi], and every step must shrink it. The array is just where candidates live. This reframing is what will later let us binary-search over answers instead of array indices.

Important — the invariant (say it out loud)

At every moment during the loop: everything before lo is too small, everything after hi is too big, and if the target exists at all, it lives inside [lo..hi]. Every branch you write must preserve this sentence. When lo passes hi, the space is empty → the target isn't there. This one sentence is the difference between "memorising templates" and "being able to derive any variant".

The base template

Two boundary pointers and one midpoint:

  • lo — the leftmost candidate index (starts at 0).
  • hi — the rightmost candidate index (starts at len(nums) - 1).
  • mid = (lo + hi) // 2 — the candidate we test this round.
Python
def binary_search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:                  # space non-empty → keep going
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid               # found
        elif nums[mid] < target:
            lo = mid + 1             # target is in the RIGHT half — kill the left
        else:
            hi = mid - 1             # target is in the LEFT half — kill the right
    return -1                        # lo passed hi → empty space → not present
Tip — why mid + 1 / mid - 1 and never mid

nums[mid] has already been tested and ruled out. Keeping it inside [lo..hi] would mean re-testing it forever. Always move past the element you just eliminated. This is the single rule that prevents both the infinite loop and most off-by-one bugs.

⚠️ Dedicated Warning — the infinite loop

If a branch sets lo = mid instead of lo = mid + 1, watch what happens when lo = 3, hi = 4: mid = 3, suppose nums[3] < target → lo = mid = 3. Nothing changed. Same lo, same hi, same mid — forever. The loop never terminates. The fix is structural, not accidental: after comparing, move strictly past mid (mid + 1 or mid - 1). If your binary search hangs, look here first.

⚠️ Dedicated Warning — off-by-one boundaries

The template pairs hi = len(nums) - 1 with while lo <= hi. Both halves of that deal matter:

  • hi = len(nums) with <= → mid can hit index n → IndexError.
  • hi = len(nums) - 1 with while lo < hi → when the space shrinks to one cell (lo == hi), the loop quits without testing it — silently wrong on targets at the edges.

Rule of thumb for this whole part: hi = n - 1 goes with while lo <= hi, and both moves go past mid (mid ± 1). Pick the trio, keep it consistent, and every variant below becomes a small edit.

Tip — the midpoint formula habit

We use mid = (lo + hi) // 2. In C/C++/Java, lo + hi can overflow the integer type when both are huge (a famous bug lived in the Java standard library for 9 years), so professionals there write lo + (hi - lo) // 2. Python integers can't overflow — but write the safe form when you meet those languages, and knowing why it exists marks you as someone who understands the machinery.

Watch the search space shrink

Below, the highlighted cells are the surviving candidates — the current [lo..hi]. Everything dim outside them has already been proven too small or too big. Target: 16 in [2, 5, 8, 12, 16, 23, 38, 56].

20L
51
82
123M
164
235
386
567R
step 1 — lo=0, hi=7, mid=3 → nums[3]=12 < 16 → everything up to index 3 is too small → lo = 4
20
51
82
123
164L
235M
386
567R
step 2 — lo=4, hi=7, mid=5 → nums[5]=23 > 16 → everything from index 5 up is too big → hi = 4
20
51
82
123
164LMR
235
386
567
step 3 — lo=hi=mid=4 → nums[4]=16 ✅ return 4. Space went 8 → 4 → 1. Three comparisons, not five.

And the miss case: search for 17 in the same array — step 1 kills the left half, step 2 (23 > 17) sets hi = 4, step 3 (16 < 17) sets lo = 5. Now lo > hi — the space is empty, and the loop exits with -1. No special "not found" logic needed: an empty search space simply means absence.


The variant family — same skeleton, one changed line

Duplicates break the base template's spirit: in [1, 2, 4, 4, 4, 7, 9], searching for 4 can land on any of the three 4s. Many real problems need the first or last occurrence, or the position where a value would go. The beautiful part: every variant is the base template with exactly one branch rewritten. Compare them side by side:

The base: on nums[mid] == target → return immediately. Any copy will do, so the first one you touch wins.

Python
if nums[mid] == target:
    return mid               # done — stop here
elif nums[mid] < target:
    lo = mid + 1
else:
    hi = mid - 1

First occurrence: on nums[mid] == target → record mid as a candidate answer, then keep hunting LEFT (hi = mid - 1). An earlier copy might exist in the left half. The loop no longer returns inside — it ends with the last recorded candidate.

Python
if nums[mid] == target:
    ans = mid                # good candidate — but look further left
    hi = mid - 1
elif nums[mid] < target:
    lo = mid + 1
else:
    hi = mid - 1

Last occurrence: the mirror image — on nums[mid] == target → record mid, keep hunting RIGHT (lo = mid + 1):

Python
if nums[mid] == target:
    ans = mid                # good candidate — but look further right
    lo = mid + 1
elif nums[mid] < target:
    lo = mid + 1
else:
    hi = mid - 1

Notice: first and last differ in one character of direction. That's the whole family — learn to derive them, not memorise them.

First occurrence — full walk-through

Python
def first_occurrence(nums, target):
    lo, hi = 0, len(nums) - 1
    ans = -1                          # stays -1 if target never appears
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            ans = mid                 # candidate found...
            hi = mid - 1              # ...but an earlier one may live left
        elif nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return ans
10L
21
42
43M
44
75
96R
step 1 — lo=0, hi=6, mid=3 → nums[3]=4 == target → record ans=3, keep searching left → hi=2
10L
21M
42R
43
44
75
96
step 2 — lo=0, hi=2, mid=1 → nums[1]=2 < 4 → left half is too small → lo=2
10
21
42LMR
43
44
75
96
step 3 — lo=hi=mid=2 → nums[2]=4 == target → record ans=2, hi=1 → lo(2) > hi(1) → stop, return 2 ✅
Tip — the invariant never changed

Re-read those three steps with the magic sentence: everything before lo is too small, everything after hi is too big. On == we didn't know whether an earlier copy exists, so we shrank the space and kept the best answer so far. The recorded ans is simply the best candidate that survived shrinking.

Last occurrence — dry run (mirror image)

Dry Run — nums = [1, 2, 4, 4, 4, 7, 9], target = 4 (last occurrence)
lo=0 hi=6 mid=3 → nums[3]=4 == target → ans=3, go RIGHT → lo=4   [space: 4..6]
lo=4 hi=6 mid=5 → nums[5]=7 > 4 → too big → hi=4                 [space: 4..4]
lo=4 hi=4 mid=4 → nums[4]=4 == target → ans=4, go RIGHT → lo=5   [space empty]
lo(5) > hi(4) → stop → return 4 ✅

Same array, same target, opposite drift: the search space 0..6 → 4..6 → 4..4 marches right, and the last recorded candidate is the final 4.

Lower bound and upper bound

Two more siblings — and these don't even need the target to be present:

  • Lower bound — the first index whose value is >= target. (Where target would be inserted to keep the array sorted.)
  • Upper bound — the first index whose value is > target. (Just past the last copy of target.)
Concept — rewrite the question as a boundary

"First value ≥ target" is a boundary in the array: to its left everything is < target, from it rightward everything is >= target. Boundaries are perfect for binary search — each mid tells you which side of the boundary you're on. What changed vs the base? The == case is absorbed: a value equal to target is "big enough", so it joins the right side and we keep searching left for a better candidate.

Python
def lower_bound(nums, target):
    lo, hi = 0, len(nums) - 1
    ans = len(nums)               # default: EVERY value is smaller → insert at the end
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] >= target:   # mid is "big enough" — a candidate,
            ans = mid             # but maybe an earlier one exists
            hi = mid - 1
        else:                     # too small — everything left of mid too
            lo = mid + 1
    return ans

def upper_bound(nums, target):
    lo, hi = 0, len(nums) - 1
    ans = len(nums)
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] > target:    # strictly greater — that's the only change
            ans = mid
            hi = mid - 1
        else:
            lo = mid + 1
    return ans
Dry Run — lower_bound([2, 5, 8, 12, 16, 23, 38, 56], 17)
lo=0 hi=7 mid=3 → 12 >= 17? no  → too small → lo=4        [space: 4..7]
lo=4 hi=7 mid=5 → 23 >= 17? yes → ans=5, hi=4             [space: 4..4]
lo=4 hi=4 mid=4 → 16 >= 17? no  → too small → lo=5        [space empty]
lo(5) > hi(4) → return 5  ✅  (nums[5]=23 is the first value ≥ 17)
20
51
82
123
164
235
386
567
result of lower_bound(…, 17) — the boundary sits here: everything left is < 17, from here right it's ≥ 17
query on [1, 2, 4, 4, 4, 7, 9]lower_boundupper_boundmeaning
target = 425copies of 4 occupy indices 2..4
target = 5 (absent)555 would be inserted at index 5
target = 000before everything
target = 107 (= n)7 (= n)after everything — note both return len(nums)
Tip — count occurrences for free

Copies of target fill exactly [lower_bound .. upper_bound - 1], so count = upper_bound(target) - lower_bound(target). For the table above: 5 - 2 = 3 occurrences of 4. Two O(log n) queries beat one O(n) scan.

Search Insert Position

Classic beginner problem: given a sorted array and a target, return the index where target is found — or where it should be inserted. Compare with the table above: that is literally the lower bound. Target present → lower bound lands on its first copy; absent → it lands on the insertion slot; target bigger than everything → it returns len(nums), the append position. One template, three behaviours:

Python
def search_insert(nums, target):
    lo, hi = 0, len(nums) - 1
    ans = len(nums)               # target larger than everything → insert at end
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] >= target:
            ans = mid
            hi = mid - 1
        else:
            lo = mid + 1
    return ans

# search_insert([2, 5, 8, 12, 16, 23, 38, 56], 17) → 5
# search_insert([2, 5, 8], 1)                      → 0  (before everything)
# search_insert([2, 5, 8], 99)                     → 3  (= len, after everything)
# search_insert([2, 5, 8], 5)                      → 1  (first copy of 5)
Common Mistake

Using ans = -1 as the default in lower-bound-style problems. When the target is bigger than everything, the correct answer is len(nums) — not -1. Ask yourself: "if the loop never finds a candidate, what is the truest answer?" For "find index" it's -1; for "first position where…" it's usually n.


Binary search on the answer — the superpower move

So far the search space was an array. The real superpower: the search space can be the set of possible answers. Suppose the question is "what is the smallest speed/capacity/value X such that something becomes possible?" If feasibility behaves monotonically — impossible, impossible, …, possible, possible — then there is a clean yes/no boundary in the answer space, and binary search can find it:

guess an answer (mid)
→
check: is mid good?
→
yes → try smaller
→
no → go bigger
Concept — what "monotonic" buys you

The check must be all-False then all-True (or the reverse) across the range. "Can we finish all piles at speed s?" — if yes at speed 10, it's automatically yes at 11, 12, 13 (faster never hurts). That heredity is what lets one check kill half the candidate answers. No sorted array needed — sortedness of behaviour is enough.

Example A — integer square root

Problem: return floor(sqrt(x)) without using sqrt. For x = 50 the answer is 7 (7² = 49 ≤ 50, but 8² = 64 > 50).

Monotonic question: "is m small enough that m² ≤ x?" — False for big m, True for small m. We want the largest m where the answer is True → record candidates and drift the space rightward. The search space is not the array — it's the range of possible answers [0..x].

Python
def my_sqrt(x):
    lo, hi = 0, x
    ans = 0
    while lo <= hi:
        mid = (lo + hi) // 2
        if mid * mid <= x:
            ans = mid        # mid works — but a bigger one might too
            lo = mid + 1
        else:
            hi = mid - 1     # mid² too big — mid and everything right is out
    return ans
Dry Run — my_sqrt(50): watch [lo..hi] shrink
lo=0  hi=50 mid=25 → 25²=625 > 50 → too big  → hi=24   [space: 0..24]
lo=0  hi=24 mid=12 → 12²=144 > 50 → too big  → hi=11   [space: 0..11]
lo=0  hi=11 mid=5  → 5²=25  ≤ 50 → works     → ans=5, lo=6   [space: 6..11]
lo=6  hi=11 mid=8  → 8²=64  > 50 → too big  → hi=7    [space: 6..7]
lo=6  hi=7  mid=6  → 6²=36  ≤ 50 → works     → ans=6, lo=7   [space: 7..7]
lo=7  hi=7  mid=7  → 7²=49  ≤ 50 → works     → ans=7, lo=8   [space empty]
lo(8) > hi(7) → return 7 ✅

Compare with the classic alternative — trying m = 1, 2, 3, … one at a time — which is O(√x). Binary search over the answer is O(log x): for x = 10¹⁸ that's 60 steps instead of a billion.

10.1 Minimum Eating Speed (binary search on the answer)

🟠 L3 · Recognize
Problem

Piles of bananas piles[i] must be eaten within h hours. Each hour you pick one pile and eat speed bananas from it (a smaller pile finishes early and the hour is still spent). Return the minimum integer speed that finishes everything within h hours.

Input / Output

piles = [3, 6, 7, 11], h = 8 → 4

Key Observation

Try to brute-force the answer: speed 1? 2? 3? … each trial costs O(n) to check, and speeds go up to 10⁹ — hopeless. But look at the shape of the question: "is speed s fast enough?" is a yes/no question whose answer only flips once (no → no → … → yes → yes). A boundary in the answer space → binary search the speed itself. Range: lo = 1 (can't eat zero), hi = max(piles) (faster than the biggest pile is never needed — no pile takes more than one hour).

Optimal Idea

Binary search speed in [1..max(piles)]. Check(speed) = total hours needed at that speed ≤ h. Feasible → record and try slower; infeasible → go faster.

Python Solution
Python
def min_eating_speed(piles, h):
    def hours_needed(speed):
        hours = 0
        for p in piles:
            hours += (p + speed - 1) // speed   # ceiling division
        return hours

    lo, hi = 1, max(piles)
    ans = hi                        # fastest speed always works (one hour per pile)
    while lo <= hi:
        mid = (lo + hi) // 2
        if hours_needed(mid) <= h:  # mid is fast enough
            ans = mid               # record it — but maybe slower also works
            hi = mid - 1
        else:                       # too slow
            lo = mid + 1
    return ans

The ceiling trick (p + speed - 1) // speed: pile 11 at speed 4 needs (11 + 3) // 4 = 3 hours — one hour per full 4 bananas plus one for the remainder. Python's math.ceil(p / speed) says the same thing; the integer form avoids float rounding on huge numbers.

Dry Run
Dry Run — piles = [3, 6, 7, 11], h = 8: the answer space 1..11 shrinking
lo=1 hi=11 mid=6 → hours(6) = 1+1+2+2 = 6  ≤ 8 → feasible → ans=6, hi=5   [space: 1..5]
lo=1 hi=5  mid=3 → hours(3) = 1+2+3+4 = 10 > 8 → too slow → lo=4          [space: 4..5]
lo=4 hi=5  mid=4 → hours(4) = 1+2+2+3 = 8  ≤ 8 → feasible → ans=4, hi=3   [space empty]
lo(4) > hi(3) → return 4 ✅
11
22
33
44
55
66M
77
88
99
1010
1111
candidate speeds 1..11, mid=6 — feasible! every speed above 6 is now pointless → space becomes 1..5
11
22
33M
44
55
66
77
88
99
1010
1111
space 1..5, mid=3 — infeasible! speed 3 (and anything slower) is dead → space becomes 4..5
11
22
33
44LM
55R
66
77
88
99
1010
1111
space 4..5, mid=4 — feasible → ans=4, try slower → space empty → answer 4 ✅
Complexity

Time O(n · log(max(pile))) — log(max) binary-search rounds, each check O(n). Space O(1).

Edge Cases
  • len(piles) == h → answer is max(piles) (exactly one hour per pile).
  • h much larger than n → answer collapses toward 1; the search handles it automatically.
  • lo starts at 1, not 0 — speed 0 divides by zero and eats nothing.
Common Mistakes
  • Binary-searching the piles instead of the speed — the piles' order is irrelevant; the answer space is speeds.
  • Forgetting the ceiling: pile 7 at speed 4 takes 2 hours, not 1.75.
  • Setting hi = sum(piles) — correct but needlessly slow; max(piles) is the honest upper bound.
Pattern Recognition
Pattern Insight

"Find the minimum speed / capacity / size / value such that a condition holds" (often phrased "within h hours / D days / k operations") → binary search on the answer, if the condition is monotonic in the answer. Recognise the shape: minimise a quantity whose feasibility only flips once.

What You Learned

  • Binary search halves a search space each step: O(log n) instead of O(n) — 20 steps for a million items.
  • The invariant "everything before lo is too small, everything after hi is too big" drives every variant.
  • The safe template: hi = n-1, while lo <= hi, mid = (lo+hi)//2, move to mid ± 1.
  • First/last occurrence record the candidate and keep shrinking toward the boundary; lower/upper bound absorb == into one side; search-insert is the lower bound.
  • Binary search also works on answers, not just arrays — whenever a yes/no feasibility check flips exactly once.
  • Infinite loops come from not moving past mid; wrong answers come from mismatched boundary conventions.

Pattern Recognition

Four triggers for binary search: (1) the input is sorted and you're looking something up; (2) you need a boundary — first/last/insert position; (3) the answer is a number in a range and a minimum/maximum such that condition question; (4) you'd otherwise scan a huge range one step at a time. In every case ask: what exactly is my search space, and does one comparison really kill half of it? If yes — binary search. If the array is unsorted but order doesn't matter, sort first (Part 9), then search.

Complexity Summary

VariantTimeSpaceThe one changed line
Base search (any copy)O(log n)O(1)return on ==
First occurrenceO(log n)O(1)on == → ans=mid, hi=mid-1
Last occurrenceO(log n)O(1)on == → ans=mid, lo=mid+1
Lower bound (≥)O(log n)O(1)>= joins the right side
Upper bound (>)O(log n)O(1)> joins the right side
Search insert positionO(log n)O(1)= lower bound, default n
Binary search on answerO(check) · log(range)O(1)search space = answers, not indices

Common Mistakes

  • Writing lo = mid (or hi = mid) instead of mid ± 1 → infinite loop.
  • Mixing conventions: hi = len(nums) with while lo <= hi → IndexError; hi = n-1 with while lo < hi → last candidate never tested.
  • Returning on the first == when first/last occurrence was asked.
  • Default ans = -1 in boundary problems where the true fallback is len(nums).
  • Binary-searching unsorted data (sorting first is allowed if you don't need original order — otherwise use a hash set / linear scan).
  • In on-answer problems: forgetting to verify the check is monotonic, or starting lo at 0 where 0 is not a valid answer.

Mini Quiz

Q1. Worst case on a sorted array of 1,000,000 elements: how many comparisons does binary search make?
Each comparison halves the space: 2²⁰ ~ 1,048,576, so 20 rounds finish it. That's log₂(n), rounded up.
Q2. In a first occurrence search, when nums[mid] == target you should…
A copy earlier in the array may exist, so record the candidate and keep shrinking toward the left. Returning immediately is the base template's move, not this variant's.
Q3. The invariant of the loop is: everything before lo is ___, everything after hi is ___.
The space [lo..hi] is exactly the candidates that could still be the answer. Empty space → not present.
Q4. lo=3, hi=4, mid=3, nums[3] < target, and the code sets lo = mid. What happens?
lo=3, hi=4 → mid=3 → lo=mid=3 → same state forever. Move past mid: lo = mid + 1.
Q5. Which problem calls for binary search on the answer rather than on an array?
"Minimum speed such that a feasibility check passes" is a monotonic yes/no boundary over candidate speeds — a search space of answers.

Practice Problems

P1 — Is the Target Present?

🟢 L1

Given a sorted list, return True if target exists in it, else False.

Show Hint

The base template, with the return value swapped.

Show Approach

Run the base loop; return True on any hit. Falling out of the loop means the space emptied — return False.

Show Solution
Python
def contains(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return True
        elif nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return False

# contains([2, 5, 8, 12], 8) → True · contains([2, 5, 8, 12], 7) → False

P2 — Count Occurrences in O(log n)

🟡 L2

Sorted list, target possibly repeated: count its occurrences. [1, 2, 4, 4, 4, 7, 9], target 4 → 3. Full scan is O(n) — do it in O(log n).

Show Hint

Copies occupy one contiguous block. Which two of this part's functions give you that block's edges?

Show Approach

first = first_occurrence(...); if -1 → count 0. Otherwise last = last_occurrence(...) and the count is last - first + 1. (Equivalently upper_bound - lower_bound.)

Show Solution
Python
def count_occurrences(nums, target):
    first = first_occurrence(nums, target)
    if first == -1:
        return 0
    last = last_occurrence(nums, target)
    return last - first + 1

# count_occurrences([1, 2, 4, 4, 4, 7, 9], 4) → 3
# count_occurrences([1, 2, 4, 4, 4, 7, 9], 5) → 0

P3 — Arranging Coins (on-answer practice)

🟡 L2

You have n coins and build a staircase: row 1 needs 1 coin, row 2 needs 2, … Return the number of complete rows. n = 8 → 3 (rows 1+2+3 = 6 coins used, row 4 incomplete).

Show Hint

How many coins do k complete rows need in one formula? Is "k rows are affordable" monotonic in k?

Show Approach

k rows cost k(k+1)/2 coins — increasing in k, so feasibility flips once. Binary search k in [0..n] for the largest k with k(k+1)/2 ≤ n.

Show Solution
Python
def arrange_coins(n):
    lo, hi = 0, n
    ans = 0
    while lo <= hi:
        mid = (lo + hi) // 2
        if mid * (mid + 1) // 2 <= n:   # mid rows are affordable
            ans = mid                    # try more rows
            lo = mid + 1
        else:
            hi = mid - 1                 # too many rows
    return ans

# arrange_coins(8) → 3 · arrange_coins(6) → 3 · arrange_coins(1) → 1

Challenge Problem

Challenge — First Bad Version

🟠 L3 · Recognize

Versions are numbered 1..n. A helper is_bad(v) tells you if version v is buggy. Every version after the first bad one is also bad. Minimise calls to is_bad while returning the first bad version. (This is the "minimum capacity/threshold" pattern wearing a software hat: the check flips exactly once.)

Show Hint

The versions are not an array you can index — but the question "is version v bad?" is a monotonic yes/no check over v = 1..n.

Show Approach

Binary search v in [1..n]: bad → record and search earlier; good → search later. The ans = n initialiser is a safe placeholder — the problem guarantees at least one bad version, so the loop always overwrites it with a real candidate. O(log n) checks instead of n.

Show Solution
Python
def first_bad_version(n, is_bad):
    lo, hi = 1, n
    ans = n                      # placeholder — some version is guaranteed bad
    while lo <= hi:
        mid = (lo + hi) // 2
        if is_bad(mid):
            ans = mid            # bad — an earlier bad one may exist
            hi = mid - 1
        else:
            lo = mid + 1         # good — the boundary is to the right
    return ans

# is_bad = lambda v: v >= 4, n = 10 → first_bad_version(10, is_bad) → 4
# trace: mid=5 bad → ans=5,hi=4 · mid=2 good → lo=3 · mid=3 good → lo=4
#        mid=4 bad → ans=4,hi=3 · lo>hi → 4 ✅
# if EVERY version is bad, the same code returns 1 — the first of them.

Revision Checklist

PART 11

Stack

One rule — last in, first out — that quietly powers undo, browser back, bracket matching and every function call you have ever made.

Some problems are about order: which item must be handled next? When the answer is always the one you touched most recently, you are holding a stack. It is the simplest container in this book — a list you only touch at one end — yet it drives undo buttons, browser history, bracket checkers and the call stack beneath every Python program you have ever run.

The Rule — Last In, First Out

Think of a stack of plates. You place a plate on top, and you take a plate from the top. The plate that arrived last leaves first. That is the entire contract, called LIFO — Last-In, First-Out. The top is the only entrance and the only exit; the bottom is unreachable until everything above it is gone.

Concept

A stack keeps items in reverse order of arrival. It is defined by three operations: push (place on top), pop (remove and return the top), and peek (read the top without removing). "Is it empty?" is the fourth question you will ask constantly.

Stack state — watch the top
after push 10        after push 20        after push 30        after pop → 30
   +------+             +------+             +------+             +------+
   |  10  | ← top       |  20  | ← top       |  30  | ← top       |  20  | ← top
   +------+             |  10  |             |  20  |             |  10  |
                        +------+             |  10  |             +------+
                                             +------+

Notice what pop returned: 30, the newest item. Push 10, 20, 30 and pop everything → you get 30, 20, 10. Arrival order, reversed. That reversal is the superpower we will exploit in every problem below.

You Already Use a Stack Every Day

Before we write a line of code, look around — stacks are not an invention for interviews, they are how interactive software already behaves:

Undo & Browser Back

Every editor keeps your recent changes on a stack. Ctrl+Z pops the most recent change, not the oldest one. Your browser's Back button pops the most recent page the same way. Undo three times and your last three actions are replayed backwards — exactly LIFO.

Function Calls (the Call Stack)

When function f() calls g(), Python pushes f's paused state and runs g. When g returns, that state is popped and f resumes. Ten nested calls must unwind in reverse order — the deepest function finishes first. That mechanism is literally named the call stack.

And one you will solve in minutes: when your editor highlights the matching bracket under your cursor, it is matching each closer against the most recent unmatched opener — a stack, as you are about to prove.

You type "cat", then "dog", then "bird", then press Ctrl+Z three times. Which word disappears first?
Undo pops the top of the change stack — the newest edit. LIFO in the wild.

Your First Stack — It Is Just a Python List

Python lists already make perfect stacks, because append and pop both work on the right end — and the right end is O(1). Treat the end of the list as the top:

Python
stack = []
stack.append(10)      # push 10
stack.append(20)      # push 20
stack.append(30)      # push 30
print(stack)          # [10, 20, 30]   ← 30 is on top

top = stack[-1]       # peek → 30 (stack unchanged)
print(len(stack))     # 3

while stack:          # pop until empty
    print(stack.pop())    # prints 30, 20, 10  — arrival order reversed
Stack operationPython list codeWhat it doesCost
push xstack.append(x)put x on topO(1)
popstack.pop()remove and return the topO(1)
peekstack[-1]read the top, keep itO(1)
is empty?not stackTrue when nothing is leftO(1)
sizelen(stack)number of itemsO(1)
Which line reads the top of the stack without removing it?
stack[-1] is a peek — it reads the last element in place. pop() removes it, stack[0] is the bottom, and len only counts.
Common Mistake

A stack only ever touches the top. stack.pop(0) and stack.insert(0, x) work on the bottom and shift the entire list — they are not stack operations, and they are slow (you will see exactly why in Part 12). If a "stack" solution reaches for index 0, stop: either you want the other end, or you do not want a stack.

Problem 11.1 — Valid Parentheses

11.1 Valid Parentheses

🟢 L1 · Understand
matching pairs single pass most recent first
Problem

Given a string containing only ( and ), return True if the parentheses are balanced: every opening bracket is closed later, and no closing bracket appears before its partner. "(()())" → True. "())(" → False. "((" → False.

Input / Output

"(()())" → True  ·  "())(" → False  ·  "" → True

Key Observation

Scan left to right. When you meet a (, it is waiting for a future ). When you meet a ), it must pair with the most recently waiting ( — not an older one. "Most recently waiting" is precisely the stack top.

Optimal Idea

Push every (. On every ), pop — but if the stack is empty there is nothing to pair with, so fail immediately. At the end, the stack must be empty: leftover (s mean unclosed brackets. Two failure modes, both handled:

  • a ) arrives with an empty stack → close with no open → False
  • the string ends with opens still on the stack → open with no close → False
Python Solution
Python
def is_balanced(s):
    stack = []                     # holds unmatched '('
    for ch in s:
        if ch == '(':
            stack.append(ch)       # push: now waiting for a ')'
        else:                      # ch == ')'
            if not stack:          # nothing to match → invalid
                return False
            stack.pop()            # matched one waiting '('
    return len(stack) == 0         # every '(' must have found a partner
Dry Run — s = "(()())", char by char
Dry Run — s = "(()())"
char 1: '(' → push                stack: ['(']
char 2: '(' → push                stack: ['(', '(']
char 3: ')' → pop                 stack: ['(']
char 4: '(' → push                stack: ['(', '(']
char 5: ')' → pop                 stack: ['(']
char 6: ')' → pop                 stack: []
end    : stack is empty → ✅ balanced
(0
(1top
after reading "((" — two opens are waiting, top = index 1
(0top
 1
after "(()" — one '(' still waiting for its partner
Dry Run — a failing string: s = "())("
Dry Run — s = "())("
char 1: '(' → push                stack: ['(']
char 2: ')' → pop                 stack: []
char 3: ')' → stack is empty → ❌ nothing to match → return False
(we never even reach the 4th char)
Complexity

Time O(n) — one pass, O(1) work per character · Space O(n) worst case (a string of all ( pushes everything).

Edge Cases
  • Empty string → stack starts and stays empty → True.
  • Single ( or single ) → False, by the two rules above.
  • All opens "(((" → survives the loop, fails the final emptiness check.
Common Mistakes
  • Checking only the counter without the "pop from empty" guard: ")(" would wrongly pass as "one open, one close".
  • Forgetting the final len(stack) == 0 check — "(((" would wrongly pass.
  • Popping before checking emptiness → IndexError instead of a clean False.
Pattern Recognition
Pattern Insight

"Every closer must match the most recent still-open item" ⇒ stack. Anytime a rule refers to the latest unfinished thing — brackets, undo, backtracking — the stack is already the answer.

Problem 11.2 — Remove All Adjacent Duplicates

11.2 Remove All Adjacent Duplicates

🟡 L2 · Apply
cascading deletions one pass
Problem

Repeatedly delete pairs of equal adjacent letters until no pair remains. Deleting a pair makes its two outside neighbours adjacent, which can create a new pair. Return the final string. "abbaca" → "ca" (delete bb, then the resulting aa). "azxxzy" → "ay".

Input / Output

"abbaca" → "ca"  ·  "azxxzy" → "ay"

Key Observation

Deleting a pair makes its neighbours become neighbours — deletions cascade. So the character that matters when a new character arrives is "the last surviving character to my left". Keep exactly the survivors, in order, on a stack: then "the last survivor on the left" is always stack[-1].

Brute Force vs Stack
Brute Force — O(n²)

Scan the string; whenever two equal characters sit together, delete both and restart the scan. "abbaca" → "aaca" → "ca" — three scans. Worst case (nested pairs like "abcddcba") needs ~n/2 restarts → O(n²).

Stack — O(n)

Feed characters one by one. Before pushing, ask: "am I equal to the top?" If yes, the pair dies on contact — and the stack automatically brings the next victim pair together. One pass, no restarts. What disappeared: the repeated re-scanning.

Python Solution
Python
def remove_duplicates(s):
    stack = []
    for ch in s:
        if stack and stack[-1] == ch:
            stack.pop()          # ch pairs with the top → both vanish
        else:
            stack.append(ch)     # ch survives (for now)
    return ''.join(stack)
Dry Run
Dry Run — s = "abbaca"
'a' → stack empty        → push    stack: ['a']
'b' → top 'a' ≠ 'b'      → push    stack: ['a', 'b']
'b' → top 'b' == 'b'     → pop     stack: ['a']          (bb deleted)
'a' → top 'a' == 'a'     → pop     stack: []             (aa deleted — the cascade!)
'c' → stack empty        → push    stack: ['c']
'a' → top 'c' ≠ 'a'      → push    stack: ['c', 'a']
result: ''.join → "ca" ✅

Watch step 4 carefully: after bb dies, the a that was before the bb is suddenly the top — so the incoming a kills it too. The stack made that "suddenly adjacent" comparison free.

Complexity

Time O(n) — each character is pushed once and popped at most once · Space O(n) for the stack.

Edge Cases
  • All pairs, e.g. "aabb" → "" (empty result).
  • No pairs at all → the string returns unchanged.
  • Odd runs like "aaa" → one a survives: push, push, pop → "a".
Common Mistakes
  • Comparing ch with the wrong element — it must be the top stack[-1], not stack[0].
  • Forgetting the stack and guard → IndexError on the first character.
  • Returning stack instead of ''.join(stack).
Pattern Recognition
Pattern Insight

When "an event cancels the most recent surviving item, and survivors re-become neighbours", model the survivors as a stack. Same skeleton as matching brackets — with values instead of brackets.

Problem 11.3 — Next Greater Element 🟠 L3 · Recognize early-intermediate

This one is your first taste of a genuinely clever stack trick. We will build it gently, with no jargon — the official name ("monotonic stack") can wait until you have already understood the idea.

For each element of an array, find the first element to its right that is strictly bigger. If none exists, the answer is -1.

nums = [4, 5, 2, 25] → [5, 25, 25, -1]

  • 4 → the next bigger thing to its right is 5
  • 5 → 25
  • 2 → 25 (5 is to its left, so it does not count)
  • 25 → nothing bigger follows → -1

For every index, walk right until you find a bigger value:

Python
def next_greater_brute(nums):
    result = []
    for i in range(len(nums)):
        answer = -1
        for j in range(i + 1, len(nums)):
            if nums[j] > nums[i]:
                answer = nums[j]
                break
        result.append(answer)
    return result

Correct — but the two loops make it O(n²). For an array of 100,000 elements that is up to 10 billion comparisons. What work is being repeated? Every element re-asks "is anything to my right bigger?" even though a single sweep past the array could answer for many elements at once.

Flip the point of view. Instead of asking each element "who is your next greater?", let elements wait, and let each new element answer for everyone it beats.

Keep the waiting elements on a stack. When a new element arrives, pop every waiting element smaller than it — the new element is their next-greater answer. Then the new element joins the waiting line.

Picture people standing in a corridor, each hoping to see someone taller walk by. A tall person entering the corridor instantly dismisses everyone shorter than them (those people all found their answer) and then waits in line themselves.

Tip

Say the invariant out loud: "everything on the stack is still waiting for a bigger element." Every step of the algorithm keeps that sentence true.

Python
def next_greater(nums):
    n = len(nums)
    result = [-1] * n      # -1 = "nothing bigger to my right"
    stack = []             # indices of elements still waiting

    for i in range(n):
        # nums[i] beats every smaller waiting element
        while stack and nums[i] > nums[stack[-1]]:
            j = stack.pop()            # nums[i] is next greater for j
            result[j] = nums[i]
        stack.append(i)                # now i waits too

    return result

We push indices rather than values, because to write an answer we need to know where the waiting element lives.

Why is this O(n) with a loop inside a loop?

The while looks dangerous, but count work per element: each index is pushed exactly once and popped at most once. Total operations ≤ 2n, so the whole algorithm is O(n) despite the nesting. The while is paid for by pushes that already happened.

Dry Run — nums = [4, 5, 2, 25] (stack holds indices)
i=0, value 4 :  stack empty → push 0              stack: [0]        (4 waits)
i=1, value 5 :  4 < 5 → pop 0 → result[0] = 5     stack: []
                stack empty → push 1              stack: [1]        (5 waits)
i=2, value 2 :  5 > 2 → nobody beaten → push 2    stack: [1, 2]     (2 waits)
i=3, value 25:  2 < 25 → pop 2 → result[2] = 25   stack: [1]
                5 < 25 → pop 1 → result[1] = 25   stack: []
                stack empty → push 3              stack: [3]
end           : 25 never beaten → result[3] stays -1

result = [5, 25, 25, -1] ✅

Each value entered the stack once and left at most once — count the arrows: 4 pushes, 3 pops across the entire run.

ApproachTimeSpaceWhat work disappeared
Brute force (scan right for each i)O(n²)O(1)—
Candidates on a stackO(n)O(n)re-asking "is anything bigger to my right?"
Edge Cases
  • Strictly decreasing input, e.g. [5, 4, 3] → nobody ever pops → [-1, -1, -1].
  • Equal values are not "greater": in [2, 2] the first 2 waits forever → [-1, -1].
  • Empty array → empty result; the loop never runs.
In next-greater-element, how many times can a single index enter the stack?
Each index is appended once when it first arrives, and popped at most once when beaten. Total work ≤ 2n → O(n) overall, even with the inner while.
Pattern Insight

"For each element, find the first next element with some property (bigger / smaller / warmer …)" ⇒ keep candidates on a stack, pop them when the new element beats them. The stack order encodes "still waiting", and each new arrival resolves every candidate it dominates in one sweep.

Quick Drills

Drill — Balanced Brackets, All Three Types

🟡 L2

Extend the checker: given a string over ()[]{}, return True only if brackets close in the right order. "{[()]}" → True. "([)]" → False (the ) arrives while [ is the most recent unclosed opener).

Show Hint

A closer must match the most recent unclosed opener — which is exactly the stack top.

Show Approach

Push openers. On a closer, compare the top against a dictionary {')': '(', ']': '[', '}': '{'}. Mismatch, empty-stack pop attempt, or leftovers at the end → False.

Show Solution
Python
def is_balanced_multi(s):
    pairs = {')': '(', ']': '[', '}': '{'}
    stack = []
    for ch in s:
        if ch in '([{':
            stack.append(ch)
        else:                        # ch is a closer
            if not stack or stack[-1] != pairs[ch]:
                return False
            stack.pop()
    return len(stack) == 0
Check — s = "([)]"
'(' → push                 stack: ['(']
'[' → push                 stack: ['(', '[']
')' → top is '[' but ')' needs '(' → ❌ False

Drill — Reverse a String Using a Stack

🟢 L1

Reverse "stacks" using only push and pop. Why does it work? A stack returns items in reverse arrival order — and reversing is reverse arrival order.

Show Solution
Python
def reverse_string(s):
    stack = []
    for ch in s:          # push everything
        stack.append(ch)
    out = []
    while stack:          # pop comes out backwards
        out.append(stack.pop())
    return ''.join(out)

# reverse_string("stacks") → "skcats"

Drill — Evaluate a Postfix Expression optional · beginner

🟢 L1

In postfix (Reverse Polish) notation the operator comes after its operands: 2 1 + 3 * means (2 + 1) * 3 = 9. No brackets ever needed. Evaluate a token list like ["2", "1", "+", "3", "*"].

Show Hint

Numbers wait on a stack. When an operator arrives, it needs the two most recent numbers — pop twice.

Show Approach

Scan tokens: number → push. Operator → pop b (right operand, pushed last), pop a (left operand), push the result. One value remains at the end.

Show Solution
Python
def eval_postfix(tokens):
    stack = []
    for t in tokens:
        if t in "+-*":
            b = stack.pop()      # right operand (pushed last)
            a = stack.pop()      # left operand
            if t == '+':
                stack.append(a + b)
            elif t == '-':
                stack.append(a - b)
            else:
                stack.append(a * b)
        else:
            stack.append(int(t))
    return stack[0]

# eval_postfix(["2", "1", "+", "3", "*"]) → 9
Dry Run — tokens = ["2", "1", "+", "3", "*"]
"2" → push 2          stack: [2]
"1" → push 1          stack: [2, 1]
"+" → b=1, a=2 → 3    stack: [3]
"3" → push 3          stack: [3, 3]
"*" → b=3, a=3 → 9    stack: [9]
answer: 9 ✅

What You Learned

  • A stack is LIFO: push on top, pop from top — nothing in the middle is ever touched.
  • A Python list is a stack via append, pop(), [-1] — all O(1).
  • Matching problems ("most recent unfinished item") are stacks in disguise.
  • Survivor-style cascades (adjacent duplicates) fall out of a stack for free.
  • "Next greater/smaller" questions yield to candidates on a stack, popped when beaten — still O(n), because each element enters and leaves once.

Pattern Recognition

Reach for a stack when the problem says any of these: most recent, matching pairs, undo / go back, nesting, nearest greater or smaller element, cancel the previous one. The tell-tale is that the rule points at the latest still-open item, not the oldest — the moment a rule points at the oldest, you want a queue (next part).

Complexity Summary

TaskTimeExtra space
push / pop / peekO(1)O(1)
valid parenthesesO(n)O(n)
remove adjacent duplicatesO(n)O(n)
next greater elementO(n)O(n)

Common Mistakes

  • Popping an empty stack — always test if not stack first (or crash with IndexError).
  • Using pop(0) / insert(0, …) — that is the wrong end and O(n) per operation.
  • Forgetting the second failure mode in matching problems: leftovers on the stack at the end.
  • Confusing peek (stack[-1]) with pop (stack.pop()) during dry runs.
  • In next-greater, pushing values and then not knowing where to write the answer — push indices.

Mini Quiz

Q1. "LIFO" means…
Last-In, First-Out: the newest item is served first. (First-In, First-Out is a queue — Part 12.)
Q2. In valid parentheses, the fastest possible signal that a string is invalid is…
An unmatched closer can never be fixed later, so we return False the instant it arrives with nothing to pair with.
Q3. "azxxzy" after removing adjacent duplicates is…
Only xx cancels; the surviving z and y are not adjacent-equal, so nothing cascades: a, z, (x x cancelled), y → "ay".
Q4. Why is the stack solution for next-greater-element O(n) despite the nested while?
Count pushes and pops across the whole run: at most n of each. The inner loop's cost is charged to elements leaving the stack, never to return.
Q5. After push(1), push(2), push(3), pop(), push(4), what does stack[-1] return?
After pushing 1, 2, 3 the stack is [1, 2, 3]; pop removes 3; pushing 4 gives [1, 2, 4] — top is 4.

Practice Problems

Practice — Reverse a Word (Stack Style)

🟢 L1

Reverse a string using only push and pop (no slicing, no reversed()).

Show Hint

Pop order is reverse arrival order — that is the whole trick.

Show Approach

Push every character, then pop into a result list until the stack is empty.

Show Solution
Python
def reverse_word(s):
    stack, out = [], []
    for ch in s:
        stack.append(ch)
    while stack:
        out.append(stack.pop())
    return ''.join(out)

Practice — Backspace Compare

🟡 L2

'#' means "delete the previous typed character". Do "ab#c" and "ad#c" type the same text? (Both become "ac" → True.)

Show Hint

Simulate typing with a stack; '#' pops.

Show Approach

Build each final text with its own stack ('#' pops if non-empty), then compare the results.

Show Solution
Python
def build(s):
    stack = []
    for ch in s:
        if ch == '#':
            if stack:
                stack.pop()
        else:
            stack.append(ch)
    return ''.join(stack)

def backspace_compare(a, b):
    return build(a) == build(b)

Practice — Minimum Add to Make Parentheses Valid

🟡 L2

How many brackets must be inserted so s becomes balanced? "())" → 1, "(((" → 3, "(()" → 1.

Show Hint

Track two counters: opens still waiting, and closes that arrived with nothing to match.

Show Approach

'(' → increment open_wait. ')' → if open_wait > 0 consume it, else count an insertion. Answer = insertions + open_wait left over.

Show Solution
Python
def min_add_to_make_valid(s):
    open_wait = 0     # unmatched '(' so far
    insertions = 0    # '(' needed for unmatched ')'
    for ch in s:
        if ch == '(':
            open_wait += 1
        else:
            if open_wait > 0:
                open_wait -= 1
            else:
                insertions += 1
    return insertions + open_wait

This is the stack of 11.1 compressed into one integer — when the stack only ever holds identical items, a counter is enough.

Challenge Problem

Challenge — Stock Span

🟠 L3 · Recognize
Problem

For each day's stock price, the span is the number of consecutive days (ending today) the price has been ≤ today's price. prices = [100, 80, 60, 70, 60, 75, 85] → [1, 1, 1, 2, 1, 4, 6]. Day 5's span is 4 because 75 ≥ 60, 70, 60, 75 but 80 stops the streak.

Show Hint

Rephrase: the span of day i is the distance to the previous strictly greater price. That is next-greater-element — searching leftwards.

Show Approach

Keep indices of days still waiting for a higher price on a stack. Each new price pops every waiting price ≤ it; if the stack still has someone taller, span = i − stack top, else span = i + 1. Push i.

Show Solution
Python
def stock_span(prices):
    spans = []
    stack = []                      # indices waiting for a higher price
    for i, price in enumerate(prices):
        while stack and prices[stack[-1]] <= price:
            stack.pop()             # that day's span ends here
        if stack:
            spans.append(i - stack[-1])
        else:
            spans.append(i + 1)     # taller than everything before
        stack.append(i)
    return spans

# stock_span([100, 80, 60, 70, 60, 75, 85]) → [1, 1, 1, 2, 1, 4, 6]
Spot-check — days 4→6 of [100, 80, 60, 70, 60, 75, 85]
i=5 (75): pop 60(idx4), pop 70(idx3) → top is 80(idx1) → span = 5-1 = 4 ✅
i=6 (85): pop 75(idx5), pop 80(idx1) → top is 100(idx0) → span = 6-0 = 6 ✅

Time O(n) — same push-once / pop-once accounting as next-greater-element.

Revision Checklist

PART 12

Queue & Deque

First in, first out — the structure of every fair line, and the engine behind scheduling, level-by-level processing and more.

A stack serves the newest item. A queue serves the oldest: you join the back, you are served from the front — FIFO: First-In, First-Out. Anywhere fairness matters — printers, support tickets, CPU time-sharing — a queue is doing the work. Python gives you a proper one, and this part shows why using the wrong tool (a plain list) can quietly turn your program from fast to quadratic.

The Rule — First In, First Out

Think of any line you have ever stood in. New people join at the rear; the person at the front is served and leaves. The two ends have different jobs — that is the one structural difference from a stack, which only ever touches one end.

Concept

A queue serves items in arrival order. It is defined by two operations: enqueue (join at the rear) and dequeue (leave from the front). front = next to be served, rear = most recent arrival.

Queue state
enqueue 10   enqueue 20   enqueue 30   dequeue → 10   dequeue → 20
                                (oldest)          (next oldest)
  front                            front
    v                                v
 +----+----+----+               +----+----+
 | 10 | 20 | 30 | ← rear        | 20 | 30 | ← rear
 +----+----+----+               +----+----+

Push 10, 20, 30 onto a stack and you pop 30, 20, 10. Enqueue 10, 20, 30 into a queue and you dequeue 10, 20, 30. Same three items, opposite service order — that is the whole comparison, and it tells you which structure a problem needs: most recent ⇒ stack, oldest first ⇒ queue.

Why Not Just Use a List? — The O(n) Trap

Python lists append at the right end in O(1), so nums.append(x) is a fine enqueue. The problem is removing from the front: nums.pop(0) must keep the list contiguous, so every remaining element shifts one slot to the left.

What pop(0) really does
before:   [10 | 20 | 30 | 40 | 50]
             ^ 20←  30←  40←  50←     every element copies one slot left
after:     [20 | 30 | 40 | 50]

n - 1 copy operations — every single dequeue

Dequeue from a 1,000,000-element list and you pay ~1,000,000 element moves per dequeue. A loop that dequeues n times becomes O(n²) — the classic silent performance killer.

A list holds 1,000,000 items. Roughly how many elements move when you call nums.pop(0)?
Removing index 0 leaves a hole at the front, and the list must stay contiguous — so all remaining elements slide one slot left. That is O(n) work on every dequeue.

The Fix — collections.deque

deque (pronounced "deck", short for double-ended queue) is built from linked blocks, so both ends are O(1): removing the front does not move anything — the deque just advances its front marker. Enqueue at the rear, dequeue at the front, forever O(1):

Python
from collections import deque

q = deque()
q.append("a")          # enqueue at the rear
q.append("b")
q.append("c")
print(q)               # deque(['a', 'b', 'c'])

print(q[0])            # front → 'a'  (next to be served)
print(q[-1])           # rear  → 'c'  (newest arrival)
print(q.popleft())     # dequeue from the front → 'a'
print(len(q))          # 2
print(not q)           # False — q is not empty
Queue operationdeque codeSideCost
enqueue xq.append(x)rearO(1)
dequeueq.popleft()frontO(1)
peek frontq[0]frontO(1)
peek rearq[-1]rearO(1)
is empty?not q—O(1)
sizelen(q)—O(1)
Tip

A deque is two structures in one: append + popleft() = a queue; append + pop() = a stack. It even supports appendleft(), so you can push and pop at either end. Just never index into the middle — q[i] on a deque is O(n), unlike a list.

list.pop(0)
deque.popleft()

Cost of one dequeue on a 100,000-item queue: the list shifts ~100,000 elements; the deque shifts none.

Problem 12.1 — Generate Binary Numbers 1 to n

Given n, output the binary representations of the numbers 1 to n in increasing order, using a queue.

n = 5 → ["1", "10", "11", "100", "101"]

🟡 L2 · Apply

Look at the binary numbers in order: 1, 10, 11, 100, 101, 110, 111, …. Every number in the sequence is some earlier number with a 0 or a 1 appended:

  • 10 and 11 are the "children" of 1
  • 100 and 101 are the children of 10
  • 110 and 111 are the children of 11

So: pop the oldest unused number s, emit it, and enqueue its two children s+"0" and s+"1". Why does the queue keep the output sorted? Every child is numerically bigger than its parent, and the queue always serves the oldest (smallest) number first — FIFO does the sorting for us. With a stack, you would emit 1's grandchildren before 1's children and the order would collapse.

Python
from collections import deque

def binary_numbers(n):
    result = []
    q = deque(["1"])
    for _ in range(n):
        s = q.popleft()       # smallest number not yet printed
        result.append(s)
        q.append(s + "0")     # its two children
        q.append(s + "1")
    return result

print(binary_numbers(5))
# ['1', '10', '11', '100', '101']

The loop runs exactly n times; each run does one dequeue and two enqueues — all O(1) on a deque.

Dry Run — n = 5 (front of queue on the left)
start          q = ["1"]
pop "1"  → out "1"      q = ["10", "11"]                    (children of 1)
pop "10" → out "10"     q = ["11", "100", "101"]            (children of 10)
pop "11" → out "11"     q = ["100", "101", "110", "111"]    (children of 11)
pop "100"→ out "100"    q = ["101", "110", "111", "1000", "1001"]
pop "101"→ out "101"    q = ["110", "111", "1000", "1001"]

output: 1, 10, 11, 100, 101 ✅

Notice the queue holds numbers of at most two consecutive lengths — it stays tiny compared to n.

MetricCostWhy
TimeO(n) queue operationsn iterations × O(1) deque ops (string building adds O(n log n) character work overall)
SpaceO(n)result list plus a queue that stays small
Pattern Insight

When a problem generates items from items (each new thing is built from an earlier one) and demands output in oldest-first / level-by-level order, a queue drives the generation. This is the exact engine of breadth-first search later in the book.

Problem 12.2 — Round-Robin Task Scheduling

12.2 Round-Robin Scheduling Simulation

🟡 L2 · Apply
simulation requeueing
Problem

Three tasks need a single machine. Each gets a fixed time slice (quantum) of 2 time units; if a task is not finished when its slice ends, it goes to the back of the line and waits for its next turn. Given tasks with remaining times — A needs 5, B needs 2, C needs 4 — simulate the machine and report the completion order and finish times.

Input / Output

tasks = [("A", 5), ("B", 2), ("C", 4)], quantum = 2 → completion order ["B", "C", "A"], finish times B: 4, C: 10, A: 11

Key Observation

The machine always works on the task at the front of the line, and unfinished tasks rejoin at the rear. That is a queue with a twist — dequeue, spend min(quantum, remaining), and enqueue again if work is left. The queue is the waiting room; no extra bookkeeping needed.

Python Solution
Python
from collections import deque

def round_robin(tasks, quantum):
    q = deque(tasks)          # (name, remaining_time)
    time = 0
    finish = {}
    order = []

    while q:
        name, rem = q.popleft()
        run = min(quantum, rem)     # never run more than is left
        rem -= run
        time += run
        if rem > 0:
            q.append((name, rem))   # unfinished → back of the line
        else:
            finish[name] = time     # done → record and move on
            order.append(name)

    return order, finish
Dry Run
Dry Run — A:5, B:2, C:4 · quantum = 2
t=0   q = [A(5), B(2), C(4)]
run A for 2 → rem 3 → requeue    q = [B(2), C(4), A(3)]     t=2
run B for 2 → rem 0 → DONE B     q = [C(4), A(3)]           t=4
run C for 2 → rem 2 → requeue    q = [A(3), C(2)]           t=6
run A for 2 → rem 1 → requeue    q = [C(2), A(1)]           t=8
run C for 2 → rem 0 → DONE C     q = [A(1)]                 t=10
run A for 1 → rem 0 → DONE A     q = []                     t=11

completion order: B (t=4), C (t=10), A (t=11) ✅

Check the arithmetic: A ran 2+2+1 = 5 ✅, B ran 2 ✅, C ran 2+2 = 4 ✅, total time 11 = 5+2+4 ✅. Note the finish order (B, C, A) is not the arrival order — that fairness is the entire point of round-robin.

Complexity

Time O(r) where r is the total number of slices (each task of time t uses ⌈t / quantum⌉ slices, each an O(1) queue operation) · Space O(n) for the queue.

Edge Cases
  • Task time exactly divisible by quantum → never requeued (B finished in one slice).
  • quantum larger than every task → degrades to plain FIFO.
  • Empty task list → the while never runs; return ([], {}).
Common Mistakes
  • Running a full quantum even when rem is smaller — overshoots the clock. Always min(quantum, rem).
  • Requeueing finished tasks → infinite loop.
  • Updating time only when a task finishes — the clock advances on every slice.
Pattern Recognition
Pattern Insight

"Process items one at a time; unfinished ones come back later, in arrival order" ⇒ a queue you re-enqueue into. Any simulation where order of service matters starts as a queue.

In round-robin, what happens when a task's slice ends but it still has work left?
Unfinished tasks rejoin at the rear — the queue guarantees everyone gets interleaved turns in arrival order.

Quick Drills

Drill — Simulate a Queue

🟢 L1

Apply the operations enqueue 10, enqueue 20, dequeue, enqueue 30, dequeue to an empty queue. What is dequeued, in order, and what remains?

Show Solution
Trace
enqueue 10   q = [10]
enqueue 20   q = [10, 20]
dequeue      → 10          q = [20]
enqueue 30   q = [20, 30]
dequeue      → 20          q = [30]
dequeued order: 10, 20 · remaining: [30]
Python
from collections import deque

q = deque()
q.append(10)
q.append(20)
print(q.popleft())    # 10
q.append(30)
print(q.popleft())    # 20
print(list(q))        # [30]

Drill — First Negative in Every Window optional · gentle

🟡 L2

For each window of size k sliding over an array, report the first negative number in it (or 0 if none). nums = [12, -1, -7, 8, -15, 30, 16, 28], k = 3 → [-1, -1, -7, -15, -15, 0].

Show Hint

Only negatives matter. Remember the positions of negatives still inside the window — oldest negative position first.

Show Approach

Keep a deque of indices of negatives in the current window: append each negative's index as you meet it; when the front index has slid out of the window, popleft it. The front of the deque is then always the first negative of the window.

Show Solution
Python
from collections import deque

def first_negative(nums, k):
    q = deque()               # indices of negatives in the current window
    result = []
    for i in range(len(nums)):
        if nums[i] < 0:
            q.append(i)
        # window is [i-k+1 .. i]: drop negatives that slid out
        if q and q[0] <= i - k:
            q.popleft()
        if i >= k - 1:        # window complete → answer
            result.append(nums[q[0]] if q else 0)
    return result
Dry Run — nums = [12, -1, -7, 8, -15, 30, 16, 28], k = 3 (first windows)
i=0 (12):  no negative seen          q = []          (window not full)
i=1 (-1):  q = [1]                                   (window not full)
i=2 (-7):  q = [1, 2]  → window [12,-1,-7]  → first neg = -1 ✅
i=3 ( 8):  q = [1, 2]  → window [-1,-7,8]   → -1 ✅
i=4 (-15): q = [1,2,4] → index 1 has left the window (window starts at 2)
           → popleft → q = [2, 4] → window [-7,8,-15] → -7 ✅
i=7 (28):  q = [4] → 4 ≤ 7-3 → popleft → q = [] → window [30,16,28] → 0 ✅

result = [-1, -1, -7, -15, -15, 0]

Concept Preview — Sliding Window Maximum optional · early-intermediate

🟠 L3

This one gets a full treatment in a later part — here we only plant the idea, because it is the natural sequel to this chapter: a deque that holds candidates.

The problem. Max of every window of size k: nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 → [3, 3, 5, 5, 6, 7].

The idea. Keep a deque of indices whose values are still plausible maximums for future windows, always stored biggest-first. When a new element arrives:

  • it evicts from the back every candidate smaller than itself (they can never be a max again — the new element is bigger and younger);
  • the front is the current window's max;
  • when the front's index slides out of the window, popleft it.
One example — the arrival of 5 (window moving over [-1, -3, 5])
before:  dq = [3(1), -1(2), -3(3)]     (values of indices 1,2,3)
new 5:   -3 < 5 → evict · -1 < 5 → evict · 3 < 5 → evict
after:   dq = [5(4)]  →  max of the window containing 5 is 5 ✅

Each index enters and leaves the deque once — O(n) total, the same accounting trick you saw in next-greater-element (Part 11). File that away; the pieces will click together later.

What You Learned

  • A queue is FIFO: enqueue at the rear, dequeue from the front — the structure of fair waiting lines.
  • list.pop(0) shifts every remaining element (O(n)); deque.popleft() moves a marker (O(1)).
  • collections.deque gives O(1) at both ends — queue, stack, or both.
  • Generation problems that emit oldest-first are queue-driven (binary numbers).
  • Simulations with rejoining lines (round-robin) are queues with re-enqueueing.

Pattern Recognition

Reach for a queue when the rule points at the oldest waiting item: "serve in arrival order", "process level by level", "first come, first served", "unfinished items come back later". Stacks answer "most recent"; queues answer "longest waiting". Generation + ordered output, scheduling simulations, and any future breadth-first traversal are all queue tells.

Complexity Summary

TaskTimeExtra space
deque enqueue / dequeue / peekO(1)—
list.pop(0) on n itemsO(n)—
binary numbers 1..nO(n) queue opsO(n)
round-robin simulationO(total slices)O(n)

Common Mistakes

  • Building queues on plain lists with pop(0) — correct output, quadratic time.
  • Calling q.pop() instead of q.popleft() — silently turns your queue into a stack.
  • In generation problems, forgetting to enqueue the children (or enqueueing them in the wrong order).
  • In round-robin, running a whole quantum when the task has less time left.
  • Indexing into a deque's middle (q[i] is O(n)) in a loop.

Mini Quiz

Q1. Items A, B, C are enqueued in that order. What is the dequeue order?
FIFO serves arrival order: A joined first, so A leaves first. (C, B, A would be a stack.)
Q2. Why is deque.popleft() O(1) while list.pop(0) is O(n)?
Lists are contiguous — removing the front forces all survivors to move one slot. Deques are built from linked blocks, so the front can be detached without moving anything.
Q3. In binary-number generation, the queue's job is to…
Every child is bigger than its parent and the queue is FIFO — so numbers always come out smallest-first, no explicit sorting.
Q4. Using append + pop() on a deque gives you a…
Both operations touch the rear — last in, first out. A queue needs opposite ends: append + popleft.

Practice Problems

Practice — Hot Potato

🟢 L1

Kids stand in a circle; the potato is passed k-1 times, and the kid holding it leaves. Repeat until one winner remains. With ["A","B","C","D","E"] and k = 3, the elimination order is C, A, E, B and D wins.

Show Hint

Passing the potato = rotating the queue: popleft and immediately append.

Show Approach

Rotate k-1 times, then popleft removes the holder. Repeat while more than one kid remains.

Show Solution
Python
from collections import deque

def hot_potato(kids, k):
    q = deque(kids)
    order = []
    while len(q) > 1:
        for _ in range(k - 1):      # pass the potato
            q.append(q.popleft())
        order.append(q.popleft())   # k-th kid leaves
    order.append(q[0])              # the winner
    return order

# hot_potato(["A","B","C","D","E"], 3)
# → ['C', 'A', 'E', 'B', 'D']   (D is the winner)

Practice — Reverse a Queue Using a Stack

🟡 L2

Using only queue ops and one stack, reverse a queue: [1, 2, 3, 4] → [4, 3, 2, 1].

Show Hint

A stack reverses what passes through it (Part 11) — pour the queue in, pour it back.

Show Approach

Dequeue everything onto the stack, then pop everything back into the queue — arrival order has been reversed.

Show Solution
Python
from collections import deque

def reverse_queue(q):
    stack = []
    while q:
        stack.append(q.popleft())
    while stack:
        q.append(stack.pop())
    return q

# reverse_queue(deque([1, 2, 3, 4])) → deque([4, 3, 2, 1])

Practice — Queue from Two Stacks

🟡 L2

Implement FIFO enqueue/dequeue using two plain-list stacks. Why does it work?

Show Hint

One reversal is LIFO; two reversals cancel out.

Show Approach

Push enqueues onto inbox. To dequeue, if outbox is empty pour inbox into it (this flips the order once more), then pop outbox.

Show Solution
Python
class QueueFromStacks:
    def __init__(self):
        self.inbox = []      # arrivals land here
        self.outbox = []     # departures leave from here

    def enqueue(self, x):
        self.inbox.append(x)

    def dequeue(self):
        if not self.outbox:
            while self.inbox:              # pour: order flips
                self.outbox.append(self.inbox.pop())
        return self.outbox.pop()

Enqueue 1, 2, 3 → dequeue → the pour leaves outbox = [3, 2, 1], so you get 1, then 2, then 3 — FIFO restored. Each element is moved at most twice in its lifetime.

Challenge Problem

Challenge — First Non-Repeating Character in a Stream

🟠 L3 · Recognize
Problem

Characters arrive one at a time. After each arrival, report the first character that has appeared exactly once so far — or '#' if none. Stream "abcabc" → answers "aaabc#" (after the second a arrives, the answer moves to b; after the second c, nothing unique remains).

Show Hint

Candidates join in arrival order, and once a character repeats it can never become the answer again — dead candidates can be dropped from the front, forever.

Show Approach

Keep a count dictionary and a queue of characters still unique when they arrived. After each new character: increment its count and enqueue it; then pop from the front while the front's count exceeds 1 (it is dead — for good). The front, if any, is the answer.

Show Solution
Python
from collections import deque

def first_unique_stream(stream):
    count = {}
    q = deque()              # candidates, in arrival order
    answer = []
    for ch in stream:
        count[ch] = count.get(ch, 0) + 1
        q.append(ch)
        while q and count[q[0]] > 1:   # front repeated → dead forever
            q.popleft()
        answer.append(q[0] if q else '#')
    return ''.join(answer)

# first_unique_stream("abcabc") → "aaabc#"
Dry Run — stream = "abcabc"
'a': count a=1 · q=[a]        → answer a
'b': q=[a, b]                 → answer a
'c': q=[a, b, c]              → answer a
'a': count a=2 → pop a        q=[b, c]    → answer b
'b': count b=2 → pop b        q=[c]       → answer c
'c': count c=2 → pop c        q=[]        → answer #
output: "aaabc#" ✅

Time O(n): each character enqueues once and is dequeued at most once. The insight worth keeping: a candidate that fails is removed permanently, so the front pointer only moves forward.

Revision Checklist

PART 13

Linked List

Your first data structure built by hand — nodes holding values and pointing at each other. Pointer discipline starts here.

Every structure so far leaned on Python's list doing the storage for you. Now you build storage yourself, one node at a time: each node holds a value and a pointer to the next node. Linked lists matter less as a "thing you use daily in Python" and more as the first place you learn to think in pointers — the exact skill trees, graphs and half of all interview problems are built on.

Why Linked Lists Exist

A Python list is one contiguous block of memory. That buys you the superpower of instant access — nums[5000] is one jump — but it charges two prices:

  • Inserting at the front shifts everything. Squeeze a new element into position 0 of a million-element list and a million elements shuffle right.
  • Growth can force a full relocation. When the block is full, Python must find a bigger block and copy everything across.

A linked list takes the opposite deal: nodes can live anywhere, each holding a pointer to the next. To insert at the front you rewire two pointers — nothing moves. The price: to reach item 5000 you must walk there, node by node. Neither structure wins everywhere — and an honest comparison is the point:

OperationArray (Python list)Singly linked list
Access the i-th elementO(1) — jump by indexO(n) — walk from the head
Insert / delete at the frontO(n) — shift everythingO(1) — rewire two pointers
Insert / delete at the endO(1) amortizedO(n) walk — O(1) only if you also keep a tail pointer
Insert / delete at a known nodeO(n) — still shiftsO(1) — rewire around it
Search by valueO(n)O(n)
Memory per elementjust the valuevalue + one pointer
Memory layoutone contiguous blocknodes scattered, stitched by pointers
Concept — the honest summary

Arrays win at random access; linked lists win at front insertion and surgery on known positions. In day-to-day Python you will almost always use a list — so why learn this? Because the node-and-pointer model is the skeleton of trees and graphs, and linked-list problems are the classic test of whether you can reason about pointers without losing one.

The Node — Value + Next

Here is a linked list holding 3, 7, 12. Each box is a node: a value and a next pointer. The variable head points at the first node; the last node's next is None — the "no more nodes" marker.

Diagram
  head
    |
    v
+----------+     +----------+     +----------+
|   3   *--+---->|   7   *--+---->|  12   X  |
+----------+     +----------+     +----------+
 value  next      value  next      value  next
                                       ^
                                       X = None: no next node

The same list in the compact one-line notation this chapter uses for dry runs:

Diagram
head → [3|•] → [7|•] → [12|•] → None

Read [3|•] as "a node whose value is 3 and whose next-pointer (the dot) leads onward". Losing the head pointer loses the whole list — there is no back door, because nodes only point forward.

The Node Class — Written Once, Reused Everywhere

Python
class Node:
    def __init__(self, value):
        self.value = value
        self.next = None      # a new node points at nothing yet

Plus one small builder we will reuse in every example and dry run:

Python
def build_list(values):
    """build_list([3, 7, 12]) → head of 3 → 7 → 12 → None"""
    head = None
    tail = None
    for v in values:
        node = Node(v)
        if head is None:
            head = node        # first node: it is both head…
        else:
            tail.next = node   # …and later nodes hang off the tail
        tail = node
    return head

Operation 1 — Traversal

Almost every linked-list task is a traversal wearing a different costume: walk from the head, do something at each node, stop at None. The walking variable is usually called curr:

Python
def traverse(head):
    curr = head
    while curr is not None:
        print(curr.value)
        curr = curr.next       # one hop forward
Dry Run — curr walking the list head → [3|•] → [7|•] → [12|•] → None
start:   head → [3|•] → [7|•] → [12|•] → None
                curr
         print 3 · curr = curr.next

step 2:  head → [3|•] → [7|•] → [12|•] → None
                        curr
         print 7 · curr = curr.next

step 3:  head → [3|•] → [7|•] → [12|•] → None
                                curr
         print 12 · curr = curr.next

step 4:  curr is None → loop ends.  printed: 3, 7, 12 ✅
Common Mistake

Traverse with a walker variable (curr), never with head itself. Move head and the original starting node is gone — you cannot get back, there are no backward pointers.

Search is the same walk with a question asked at each node:

Python
def search(head, target):
    curr = head
    position = 0
    while curr is not None:
        if curr.value == target:
            return position     # found it
        curr = curr.next
        position += 1
    return -1                   # walked off the end: not present

This is exactly why access/search is O(n): to know what node 5000 contains, the only way is to hop 5000 times. An array jumps; a list walks.

Operation 2 — Insert at the Beginning

The headline feature: O(1) front insertion. Two steps, and the order of the steps is everything:

Diagram
1) create the new node              2) node.next = head   (grab the old chain)
      [5|•]                              [5|•]
                                            \
                                             v
                                   head → [3|•] → [7|•] → None

3) head = node   (officially crowned)
   head
     v
   [5|•] → [3|•] → [7|•] → None
Python
def insert_at_beginning(head, value):
    node = Node(value)
    node.next = head     # 1. new node grabs the old chain
    return node          # 2. and becomes the new head
Important — do it in this order

node.next = head must happen before you stop caring about the old head. The new node first grabs the old chain, then takes the crown. (If you reverse the two assignments in a language with manual memory, the entire rest of the list becomes unreachable — in Python it merely becomes garbage. Both are bugs.)

Operation 3 — Insert at the End

No tail shortcut here: walk to the last node (the one whose next is None) and hang the new node off it. That walk is why this is O(n) without a stored tail pointer.

Python
def insert_at_end(head, value):
    node = Node(value)
    if head is None:          # empty list: the new node IS the list
        return node
    curr = head
    while curr.next is not None:   # stop AT the last node
        curr = curr.next
    curr.next = node
    return head
Common Mistake

The loop condition is curr.next is not None, not curr is not None. With curr is not None you walk past the last node onto None — and None has no .next to attach to. Stop one node early, then attach.

Operation 4 — Delete a Node by Value

To delete a node you cannot delete it directly — you must stand on the node before it and rewire around the victim:

Diagram — deleting 7
before:   [3|•] --> [7|•] --> [12|•] → None
            curr      victim

rewire:   curr.next = curr.next.next

after:    [3|•] --------------→ [12|•] → None
          (7 is now unreachable → gone)
Python
def delete_node(head, value):
    if head is None:
        return None
    if head.value == value:      # special case: deleting the head
        return head.next         # new head is the second node
    curr = head
    while curr.next is not None and curr.next.value != value:
        curr = curr.next         # stop ON the node before the victim
    if curr.next is not None:    # found a victim to remove
        curr.next = curr.next.next
    return head
Dry Run — delete 12 from head → [3|•] → [7|•] → [12|•] → None
head.value == 12?  no (3) → enter the while
curr = [3]:  curr.next is [7], 7 ≠ 12 → step forward
curr = [7]:  curr.next is [12], 12 == 12 → stop, curr is the node BEFORE the victim
curr.next = curr.next.next   →   [7|•] now points at None
result: [3|•] → [7|•] → None ✅

The head needs its own branch because there is no node before it — deleting the head simply promotes the second node. Note the symmetry with the comparison table: deletion at a known spot is O(1) rewiring, but finding that spot by value is still O(n).

Given only the head of a singly linked list, which operation is O(1)?
Front insertion is two pointer moves — no walk. The others must traverse, since nodes only point forward.

Problem 13.1 — Reverse a Linked List

Reverse the list in place: head → [1|•] → [2|•] → [3|•] → None becomes head → [3|•] → [2|•] → [1|•] → None. Return the new head.

🟡 L2 · Apply

Walk the list once; at every node, flip its next arrow to point backwards. Two dangers force the design:

  • the moment you flip curr.next, you cut the only path to the rest of the list — so save it first in nxt;
  • after flipping, you need to remember where the reversed part ends — that is prev.

So three pointers travel together: prev (already-reversed part), curr (node being flipped), nxt (saved remainder). When curr walks off the end, prev is standing on the new head.

Tip

The loop body is always the same four lines, in this order: save → flip → advance prev → advance curr. Say it while you dry run.

Python
def reverse_list(head):
    prev = None
    curr = head
    while curr is not None:
        nxt = curr.next      # 1. save the rest of the list
        curr.next = prev     # 2. flip the arrow backwards
        prev = curr          # 3. prev advances onto curr
        curr = nxt           # 4. curr advances onto the saved rest
    return prev              # prev is the new head
Dry Run — reversing 1 → 2 → 3, one arrow flip at a time
start:    prev=None   curr=[1]
          None    [1|•]→[2|•]→[3|•]→None
           ^       ^
          prev    curr

step 1:   nxt=[2] · 1.next=None · prev=[1] · curr=[2]
          None←[1|•]   [2|•]→[3|•]→None
                ^       ^
               prev    curr          (the first arrow is flipped)

step 2:   nxt=[3] · 2.next=[1] · prev=[2] · curr=[3]
          None←[1|•]←[2|•]   [3|•]→None
                      ^        ^
                     prev     curr

step 3:   nxt=None · 3.next=[2] · prev=[3] · curr=None
          None←[1|•]←[2|•]←[3|•]
                            ^
                           prev     (curr is None → loop ends)

return prev → [3|•]→[2|•]→[1|•]→None ✅

13.1 Reverse a Linked List — wrap-up

🟡 L2 · Apply
Complexity

Time O(n) — each node is visited and rewired once · Space O(1) — three pointers, no new nodes.

Edge Cases
  • Empty list → curr starts as None, loop never runs, return prev = None.
  • Single node → one flip points it at None; returns the same node.
Common Mistakes
  • Flipping curr.next before saving it in nxt — the rest of the list is lost.
  • Advancing curr with curr.next after the flip — curr.next now points backwards; that is what nxt is for.
  • Returning curr instead of prev — curr is None when the loop ends.
Pattern Recognition
Pattern Insight

"Reverse" + "linked list" + O(1) space ⇒ three pointers walking in lockstep: save, flip, advance. The same flip appears inside palindrome checks and reordering problems.

Problem 13.2 — Find the Middle (Slow & Fast Pointers)

13.2 Middle of a Linked List

🟡 L2 · Apply
two pointers one pass O(1) space
Problem

Return the middle node of a list. For odd lengths, the exact middle: 1→2→3→4→5 → node 3. For even lengths, the second of the two middles: 1→2→3→4 → node 3.

Key Observation

Counting nodes first works (two passes), but there is a one-pass trick: send two walkers from the head — slow moves one hop per step, fast moves two. Every time fast has covered the whole list, slow has covered exactly half. When fast hits the end, slow stands on the middle.

Python Solution
Python
def find_middle(head):
    slow = head
    fast = head
    while fast is not None and fast.next is not None:
        slow = slow.next          # one hop
        fast = fast.next.next     # two hops
    return slow
Dry Run
Odd length — 1 → 2 → 3 → 4 → 5
start     slow=[1]   fast=[1]
round 1   slow=[2]   fast=[3]      (fast hopped 1→3)
round 2   slow=[3]   fast=[5]
stop      fast.next is None → slow = [3] ✅
Even length — 1 → 2 → 3 → 4
start     slow=[1]   fast=[1]
round 1   slow=[2]   fast=[3]
round 2   slow=[3]   fast=None     (3 → 4 → past the end)
stop      fast is None → slow = [3] ✅  (second middle)
Complexity

Time O(n) — fast finishes the list in ~n/2 rounds · Space O(1).

Edge Cases
  • Empty list → loop condition fails immediately → returns None.
  • One node → fast.next is None → returns that node.
  • The loop's guard checks fast and fast.next — the order matters: testing fast.next first would crash on the empty list.
Pattern Recognition
Pattern Insight

"Middle / half / k-from-end without knowing the length" ⇒ slow & fast pointers: let one move twice as far, and use the length ratio instead of counting. This pair returns in the next problem — with more dramatic consequences.

Problem 13.3 — Detect a Cycle (Slow & Fast, Round Two)

13.3 Detect a Cycle

🟡 L2 · Apply
Floyd's algorithm O(1) space
Problem

A cycle exists if some node's next chain loops back to an earlier node — the list has no None to end at: 1 → 2 → 3 → 4 → 5 → (back to 3). Return True/False. A naive solution loops forever; you need a way to notice the loop.

Brute Force vs Optimal
Marking — O(n) space

Walk the list storing every visited node in a set. If you ever meet a node you have seen, there is a cycle. Simple, correct — but it costs O(n) extra memory.

Slow & fast — O(1) space

Same two walkers as 13.2. In a loop, fast keeps lapping slow, and the gap between them shrinks by exactly 1 each step — so they must eventually stand on the same node. No memory at all. What disappeared: the visited-set.

Why must fast eventually meet slow?

Think of a circular running track. Slow does one step per tick, fast does two — so fast gains exactly one position per tick on slow. The gap between them goes 6 → 5 → 4 → 3 → 2 → 1 → 0. It shrinks by one each tick, so it can never skip past zero: when the gap hits 0, they are on the same node. On a straight list, fast simply reaches None first and the loop exits. Either way, the loop in the code terminates with a correct answer.

Python Solution
Python
def has_cycle(head):
    slow = head
    fast = head
    while fast is not None and fast.next is not None:
        slow = slow.next          # 1 hop
        fast = fast.next.next     # 2 hops
        if slow is fast:          # same NODE (not same value!)
            return True
    return False                  # fast fell off the end → no cycle
Dry Run — 1→2→3→4→5 with 5 pointing back to 3 (loop: 3→4→5→3)
Dry Run — cyclic list
start    slow=[1]    fast=[1]
step 1   slow=[2]    fast=[3]
step 2   slow=[3]    fast=[5]
step 3   slow=[4]    fast=[4]     (5 → 3 → 4)
         slow is fast → ✅ cycle detected
Complexity

Time O(n) — before meeting, fast never travels more than a bounded multiple of the list length plus the loop · Space O(1).

Edge Cases
  • Empty list or single node → loop never runs → False.
  • A node pointing to itself (a.next = a) → detected at the first check.
  • Compare with is, not ==: two nodes may hold equal values while being different nodes.
Common Mistakes
  • Checking slow == fast on values instead of node identity — duplicate values cause false positives.
  • Moving fast before the meeting check in a way that skips over slow — with the standard 1-vs-2 hops and a check each round, the gap shrinks by exactly 1 and cannot be skipped.
  • Forgetting the fast.next is not None guard → None.next crash on acyclic lists.
Pattern Recognition
Pattern Insight

"Detect a loop / find where a sequence repeats, in O(1) space" ⇒ two pointers at different speeds. The speed difference is the measurement device: on a cycle, relative speed is nonzero, so a meeting is inevitable.

Problem 13.4 — Merge Two Sorted Lists

13.4 Merge Two Sorted Linked Lists

🟡 L2 · Apply
two sorted inputs dummy node compact
Problem

Zip two sorted lists into one sorted list by rewiring next pointers (no new nodes, no values copied): 1→3→5 and 2→4 → 1→2→3→4→5.

Optimal Idea

Both lists are already sorted, so the smaller front node is always the next node of the result. Peel fronts off a and b, always taking the smaller, and let a tail pointer stitch them together. A dummy node in front removes the awkward "result is still empty" special case: every attachment becomes tail.next = chosen, and the real answer is simply dummy.next.

Python Solution
Python
def merge_sorted(a, b):
    dummy = Node(0)          # fake start node — no special cases
    tail = dummy
    while a is not None and b is not None:
        if a.value <= b.value:
            tail.next = a    # take the smaller front…
            a = a.next       # …and advance that list
        else:
            tail.next = b
            b = b.next
        tail = tail.next     # tail follows the last attached node
    tail.next = a if a is not None else b   # one list ended: glue the rest
    return dummy.next
Dry Run
Dry Run — a = 1→3→5, b = 2→4
round   a-front  b-front  pick    result so far     tail
1       1        2        1       1                 [1]
2       3        2        2       1→2               [2]
3       3        4        3       1→2→3             [3]
4       5        4        4       1→2→3→4           [4]
b is None → exit loop → glue the leftover a (the 5)

result: 1→2→3→4→5 ✅
Complexity

Time O(n + m) — every node is attached exactly once · Space O(1) — rewiring only.

Edge Cases
  • One list empty → the while never runs, the other is returned whole via the final glue line.
  • Both empty → dummy.next is None.
  • Duplicate values across lists → <= keeps the merge stable (ties take from a first).
Pattern Recognition
Pattern Insight

"Merge sorted things" ⇒ repeatedly take the smaller front; a dummy head makes the first node un-special. You will meet this exact shape again in merge sort and in merging sorted arrays.

Why does a dummy node simplify the merge?
With a dummy, every step is the same "tail.next = chosen" — including the very first one. The real head is just dummy.next.

What You Learned

  • A linked list is nodes (value + next) chained by pointers, entered only through head.
  • Arrays trade away access speed for O(1) front insertion — each structure has an honest win column.
  • Traversal, search, front insert, end insert and delete-by-value are all walks + rewires.
  • Reversal is a save → flip → advance dance with prev/curr/nxt — O(n) time, O(1) space.
  • Slow & fast pointers find the middle and detect cycles; in a loop the 1-node-per-step gap guarantees a meeting.
  • A dummy node deletes the "empty result" special case in merges.

Pattern Recognition

Think "linked list" when the input arrives as nodes or the problem forbids indexing. Inside the pattern: "reverse" ⇒ prev/curr/nxt; "middle / k-from-end / cycle" ⇒ slow & fast; "merge sorted" ⇒ front-peeling with a dummy; "delete" ⇒ stop one node early and rewire. Whenever you write curr.next.next, pause and picture the diagram before you trust it.

Complexity Summary

TaskTimeExtra space
traverse / searchO(n)O(1)
insert at beginningO(1)O(1)
insert at end (no tail pointer)O(n)O(1)
delete by valueO(n)O(1)
reverseO(n)O(1)
middle / cycle (slow & fast)O(n)O(1)
merge two sorted listsO(n + m)O(1)

Common Mistakes

  • Using head as the walker — move a curr copy instead, or you lose the list.
  • Walking off the end: stop at the last node (curr.next is not None) when you need to attach or delete.
  • Flipping curr.next without saving it first — the rest of the list vanishes.
  • Comparing node values for identity checks (== vs is).
  • Assuming O(1) deletion "by value" — the rewire is O(1), finding the spot is O(n).

Mini Quiz

Q1. In insert_at_beginning, why must node.next = head come before head = node?
If you move head first, only the new node remains reachable — the old chain's entry point is gone (nodes point forward only).
Q2. Reversal saves nxt = curr.next before flipping. What does nxt protect?
After curr.next = prev, curr's old forward link is overwritten — nxt was the only way onward.
Q3. With slow/fast on 1→2→3→4, which node does find_middle return?
slow=[2], fast=[3] after round 1; round 2 sends fast to None and slow to [3]. This loop convention returns the second of the two middles.
Q4. Why do slow and fast always meet inside a cycle?
Two hops minus one hop = one node gained per step. A gap shrinking by exactly 1 can never jump over 0 — meeting is guaranteed.
Q5. Detecting a cycle with a visited-set vs slow/fast differs mainly in…
Both are O(n) time and both are correct; the pointer version wins by using constant extra space.

Practice Problems

Practice — Largest Value in a List

🟢 L1

Return the largest value in a linked list (assume it is non-empty).

Show Hint

Plain traversal with a running best — the walk you practiced in Operation 1.

Show Approach

Start best = head.value; at each node, update best if the node's value is bigger; advance.

Show Solution
Python
def largest_value(head):
    best = head.value
    curr = head.next
    while curr is not None:
        if curr.value > best:
            best = curr.value
        curr = curr.next
    return best

Practice — Remove Duplicates from a Sorted List

🟡 L2

The list is sorted: 1→1→2→3→3 → 1→2→3. Rewire duplicates away in one pass.

Show Hint

Duplicates are adjacent — compare each node with its next.

Show Approach

If curr.value == curr.next.value, skip the twin (curr.next = curr.next.next) and do NOT advance (there may be a triple); otherwise advance.

Show Solution
Python
def remove_duplicates(head):
    curr = head
    while curr is not None and curr.next is not None:
        if curr.value == curr.next.value:
            curr.next = curr.next.next    # skip the twin
        else:
            curr = curr.next
    return head

Practice — K-th Node from the End

🟡 L2

Find the k-th node from the end in one pass (k = 2 in 1→2→3→4→5 is node 4). Return None if the list is shorter than k.

Show Hint

Send fast k hops ahead first — then the gap between fast and slow is exactly k.

Show Approach

Advance fast k times (return None if it runs out). Then move both one hop at a time; when fast is None, slow is on the answer.

Show Solution
Python
def kth_from_end(head, k):
    fast = head
    for _ in range(k):
        if fast is None:          # list shorter than k
            return None
        fast = fast.next
    slow = head
    while fast is not None:
        slow = slow.next
        fast = fast.next
    return slow
Spot-check — k = 2, list 1→2→3→4→5
fast after 2 hops = [3]
walk: slow=[2] fast=[4] → slow=[3] fast=[5] → slow=[4] fast=None
answer: [4] ✅

Challenge Problem

Challenge — Palindrome Linked List

🟠 L3 · Recognize
Problem

Return True if the list reads the same forwards and backwards: 1→2→3→2→1 → True, 1→2→2→1 → True, 1→2 → False. Can you do it in O(n) time and O(1) space — no copying into an array?

Show Hint

A palindrome's second half is the mirror image of the first. Which tools from this part produce the second half, reversed, without extra memory?

Show Approach

Three moves, all from this chapter: (1) slow & fast find the middle; (2) prev/curr/nxt reverse the half after the middle; (3) walk one pointer from the head and one from the reversed half, comparing values.

Show Solution
Python
def is_palindrome(head):
    # 1) find the middle — slow ends at the second half's first node
    slow = fast = head
    while fast is not None and fast.next is not None:
        slow = slow.next
        fast = fast.next.next
    # 2) reverse the second half
    prev = None
    curr = slow
    while curr is not None:
        nxt = curr.next
        curr.next = prev
        prev = curr
        curr = nxt
    # 3) compare first half with the reversed second half
    left, right = head, prev
    while right is not None:      # right half is equal or one shorter
        if left.value != right.value:
            return False
        left = left.next
        right = right.next
    return True
Spot-check — 1→2→3→2→1
middle:  slow ends at [3]
reverse second half:  1→2→3  and  3→2→1  (prev=[1] of the reversed half)
compare: 1=1 ✅ · 2=2 ✅ · 3=3 ✅ → True

Time O(n), space O(1). This challenge is the whole chapter in one problem: middle, reversal, pointer walking.

Revision Checklist

PART 14

Recursion

A function that calls itself — the engine behind trees, backtracking, and every problem made of smaller copies of itself.

Some problems contain smaller copies of themselves. "The factorial of 5 is 5 times the factorial of 4." "The sum of a list is the first element plus the sum of the rest." "A folder contains files and more folders." When a problem is self-similar, the shortest correct solution is a function that calls itself on the smaller copy. That idea is called recursion, and in DSA it is not a party trick — trees (Part 16), backtracking (Part 15), and grid exploration are painful to write any other way.

The two halves of every recursive function

Every correct recursive function has exactly two parts. If either is missing or wrong, the function either crashes or returns nonsense.

Concept

Base case — the smallest input, answered directly, with no self-call. It is the exit door.
Recursive case — solve a smaller version of the problem by calling the function on it, then use that answer to build the full answer.

Important

Every recursive call must move toward the base case. If the input doesn't shrink (or shrinks the wrong way), the calls never stop and Python crashes with RecursionError — we'll see exactly why later in this part.

First example: factorial

Mathematics already defines factorial recursively: n! = n × (n−1)!, with 1! = 1. The definition is the code — we just translate it.

Python
def factorial(n):
    if n <= 1:                      # base case: smallest input, answer directly
        return 1
    return n * factorial(n - 1)     # recursive case: shrink n by 1

print(factorial(4))    # 24

Before running it, ask the two questions that let you read any recursion:

Tip — the two questions

1. Where is the base case, and is it reachable? Here: n <= 1, and n drops by 1 each call, so yes.
2. If the smaller call is already correct, how do I finish the current one? Here: the smaller call gives (n−1)!, and multiplying by n finishes it. Nothing else to check.

Unrolling factorial(4) by hand makes the shape visible — the calls go down until the base case, then the answers flow back up:

Unrolling — factorial(4)
factorial(4)
= 4 * factorial(3)              ← must wait for a smaller answer
= 4 * (3 * factorial(2))        ← must wait again
= 4 * (3 * (2 * factorial(1)))  ← one more level down
= 4 * (3 * (2 * 1))             ← base case answers 1, no more calls
= 4 * (3 * 2)                   ← answers start flowing back up
= 4 * 6
= 24   ✅

What Python actually does: the call stack

When a function calls another function, Python pauses the caller, remembers exactly where it stopped (which line, which values), and starts the callee. Paused callers wait in a stack structure called the call stack. Each waiting function occupies one frame holding its own private variables — so the paused factorial(4) keeps its own n = 4 while a fresh factorial(3) runs with n = 3. Watch the stack grow while descending and shrink while returning — click through the five states:

Call stack
┌────────────────────────────────┐
│ factorial(4)                   │ ← top frame
│ needs: 4 * factorial(3)        │
└────────────────────────────────┘
      1 frame — factorial(4) paused, waiting for factorial(3)

Python pushes a frame for factorial(4). The frame remembers its own n = 4 and the fact that it is stuck on the multiplication.

Call stack
┌────────────────────────────────┐
│ factorial(2)                   │ ← top
│ needs: 2 * factorial(1)        │
├────────────────────────────────┤
│ factorial(3)                   │
│ needs: 3 * factorial(2)        │
├────────────────────────────────┤
│ factorial(4)                   │ ← bottom
│ needs: 4 * factorial(3)        │
└────────────────────────────────┘
      3 frames — each has its own private n

Every level adds one frame on top. All three callers are frozen mid-line, each holding a different n.

Call stack
┌────────────────────────────────┐
│ factorial(1)                   │ ← top
│ BASE CASE → returns 1          │
├────────────────────────────────┤
│ factorial(2)                   │
│ needs: 2 * factorial(1)        │
├────────────────────────────────┤
│ factorial(3)                   │
│ needs: 3 * factorial(2)        │
├────────────────────────────────┤
│ factorial(4)                   │
│ needs: 4 * factorial(3)        │
└────────────────────────────────┘
      4 frames — deepest point, no further calls

The base case makes no call at all — it answers immediately. This is the moment the descent stops.

Call stack
┌────────────────────────────────┐
│ factorial(4)                   │ ← top
│ got 6 → computes 4 * 6 = 24    │
└────────────────────────────────┘
      1 frame — three already popped:
      factorial(1) returned 1
      factorial(2) got 1 → returned 2
      factorial(3) got 2 → returned 6

As each frame returns, Python pops it and hands the result to the frame below, which resumes exactly where it paused.

Call stack
(stack empty)

factorial(4) returned 24 to whoever called it.

Pattern you should remember:
  descent  → stack GROWS  (one frame per call)
  base case reached → returns directly
  return   → stack SHRINKS (one frame popped per return)

The same story as a trace table. Read it top-down for the descent, bottom-up for the returns:

CallWhat it doesReturns
factorial(4)must compute 4 * factorial(3) → waits4 * 6 = 24
factorial(3)must compute 3 * factorial(2) → waits3 * 2 = 6
factorial(2)must compute 2 * factorial(1) → waits2 * 1 = 2
factorial(1)base case — answers immediately, no call1
Q1. With our base case n <= 1: return 1, what does factorial(0) return?
0 satisfies n <= 1, so the base case fires immediately and returns 1 — which is also the mathematically correct value of 0!.

Warm-up: four small recursive functions

All four use the same recipe you just saw: shrink the input, trust the smaller call, combine. Read each one by asking the two questions — not by tracing every call.

Sum of the first n numbers

sum_n(4) should be 4 + 3 + 2 + 1. That is literally "4 plus the sum of the first 3".

Python
def sum_n(n):
    if n == 0:                  # base case: sum of nothing is 0
        return 0
    return n + sum_n(n - 1)     # n plus the sum of everything below it

print(sum_n(4))    # 10
Dry Run — sum_n(4)
sum_n(4) → waits for 4 + ?
sum_n(3) → waits for 3 + ?
sum_n(2) → waits for 2 + ?
sum_n(1) → waits for 1 + ?
sum_n(0) → base case → 0
back up: 1+0=1 → 2+1=3 → 3+3=6 → 4+6=10 ✅

Reverse a string

"Reverse of 'abcde'" = "reverse of 'bcde', then stick 'a' on the end". We pass an index i instead of slicing the string — each call handles one character.

Python
def reverse_string(s, i=0):
    if i == len(s):             # walked past the end — nothing left
        return ""
    return reverse_string(s, i + 1) + s[i]   # reversed tail + current char

print(reverse_string("playbook"))    # koobyalp

Honesty note: this builds a new string at every level, so it costs O(n²) time — fine for learning recursion, but in real code s[::-1] (O(n)) wins. Recursion is the mindset here, not the fastest tool.

Sum of an array

"Sum of [3, 1, 4, 1]" = "3 + sum of [1, 4, 1]". We again carry an index i; the "smaller problem" is the same array starting at i + 1.

Python
def sum_array(nums, i=0):
    if i == len(nums):          # no elements left
        return 0
    return nums[i] + sum_array(nums, i + 1)

print(sum_array([3, 1, 4, 1]))    # 9
Why an index instead of nums[1:]?

Slicing copies the whole remaining list on every call — that turns an O(n) walk into O(n²) work. Passing an index changes nothing about the recursion idea; it just avoids re-copying. You'll meet this index trick constantly in backtracking (next part).

Find the maximum, recursively

"Max of the array" = "bigger of (first element, max of the rest)". The smaller call finds the max of everything after position i; we compare and pass the winner up.

Python
def find_max(nums, i=0):
    if i == len(nums) - 1:      # only one element left → it is the max
        return nums[i]
    rest = find_max(nums, i + 1)      # max of the rest — TRUST it
    return nums[i] if nums[i] > rest else rest

print(find_max([2, 9, 4, 7]))    # 9
Dry Run — find_max([2, 9, 4, 7])
find_max(i=0) waits: compare 2 with rest
find_max(i=1) waits: compare 9 with rest
find_max(i=2) waits: compare 4 with rest
find_max(i=3) base: only element left → 7
back up: max(4, 7)=7 → max(9, 7)=9 → max(2, 9)=9 ✅

Notice the mental move in that comment — TRUST it. That is the single most useful skill in this whole part, and it deserves its own section.

Q2. What does sum_array([]) return with our code?
On the very first call i == len(nums) is already true (0 == 0), so the base case fires and returns 0 — the sum of nothing.

Fibonacci — beautiful code, terrible speed

The Fibonacci sequence starts 0, 1, 1, 2, 3, 5, 8, … and each number is the sum of the two before it. That definition translates to recursion almost word for word:

Python
def fib(n):
    if n <= 1:                    # base cases: fib(0)=0, fib(1)=1
        return n
    return fib(n - 1) + fib(n - 2)   # two smaller calls instead of one

This is correct — and this is the famous example of recursion used badly. Look at what one call spawns:

Call tree of fib(5)
                        fib(5)
                      /        \
                fib(4)           fib(3)
               /      \          /     \
          fib(3)     fib(2)   fib(2)   fib(1)
          /    \      /   \    /   \
     fib(2)  fib(1) f(1) f(0) f(1) f(0)
      /   \
    f(1)  f(0)

fib(3) computed 2× · fib(2) computed 3× · fib(1) computed 5×

fib(3) is computed twice, fib(2) three times, and the waste multiplies at every level. Each call roughly doubles the work, so the call count grows exponentially:

CallFunction calls madeVerdict
fib(10)177instant
fib(20)21,891still instant
fib(30)≈ 2,692,537seconds — for such a tiny input

Being honest: naive Fibonacci is O(2ⁿ) time (more precisely Θ(φⁿ)) with only O(n) stack depth — the stack is fine, the recomputation is the killer. The fix (remembering answers you already computed, called memoization) belongs to the dynamic-programming part of this book; for now the lesson is: recursion is a control-flow tool, not a speed guarantee.

Q3. Why is naive fib(30) so slow while factorial(30) (same "n") is instant?
factorial makes n calls that all do different work (O(n) total). fib splits into two overlapping subproblems, so the same values are recomputed over and over — roughly 2.7 million calls for n = 30.

Where recursion earns its keep in DSA

Summing arrays with recursion is a teaching exercise — a for loop does it better. The places where recursion is the natural tool share one property: the data or the task is defined in terms of itself.

  • Trees (Part 16): a tree is "a root plus smaller trees". "Count the nodes" = 1 + count(left) + count(right) — three lines, no loop bookkeeping.
  • Backtracking (Part 15): "try a choice, solve the rest, undo the choice" is recursion with an undo step.
  • Grids / mazes: "from here, can I reach the exit?" = "can I from the cell above, below, left, or right?" — the same question, one step smaller.
  • Divide and conquer (merge sort, quicksort, binary search): split, solve halves recursively, combine.

Stack overflow: the RecursionError

Every waiting frame lives in real memory, and Python caps the stack depth — by default at roughly 1000 frames (sys.getrecursionlimit()). Blow past it and you get:

Python
def boom(n):
    return boom(n + 1)    # input GROWS — never reaches any base case

boom(1)
# RecursionError: maximum recursion depth exceeded
Warning — the two classic crashes
  • No base case, or a base case the input can never reach (e.g. starting from a negative number and stepping down) → RecursionError.
  • Input too large for recursion depth: recursing 1,000,000 times line-by-line will crash even though the logic is correct. Python does not optimize tail calls — deep linear recursion always grows the stack. If n can be huge and the task is linear, use a loop.

Raising the limit with sys.setrecursionlimit(...) exists, but treat it as a band-aid: the real fix is a smaller depth or a loop.

Q4. A recursive function's argument increases by 1 on every self-call. What happens?
Moving away from the base case means the exit door never opens; frames pile up until Python hits its ~1000-frame limit and raises RecursionError.

"Trust the recursion" — the mindset

Beginners get stuck trying to mentally trace every level of a recursion at once. Professionals don't. They use a leap of faith:

The leap of faith

When writing the recursive case, assume the smaller call already works perfectly — as if someone else implemented it. Your only job is the current level: get the base case right, shrink the input correctly, and combine the smaller answer correctly. If those three are right, the whole thing is right.

Example: "count the nodes of a tree". Don't imagine the full call tree. Just say: the count is 1 for this node, plus the count of the left subtree (a smaller tree — trust it), plus the count of the right subtree. That's the entire proof. You'll reuse exactly this reasoning on every tree problem in Part 16.

1

Nail the base case. Smallest input → direct answer, no self-call. Test it first.

2

Shrink. Decide how the call moves toward the base case (n - 1, i + 1, subtree instead of tree).

3

Combine with trust. Assume the smaller answer is correct; write only the glue that turns it into this level's answer.

4

Sanity-check one small input by hand (n = 3, not n = 30). One level of tracing is enough to catch glue mistakes.

When to write recursion vs. a loop

Both can compute anything; the choice is about fit and depth:

SituationReach forWhy
Simple linear pass: sum, max, count, filterLoopNo self-similarity; a loop is simpler and has zero call overhead
Trees, nested folders, nested listsRecursionThe data is literally defined in terms of itself
"Generate / try all combinations, permutations, paths"Recursion (backtracking)The number of nested decisions isn't known until runtime
Split-and-solve (merge sort, binary search)RecursionHalves of halves — self-similar by design
Linear task over a huge input (n in the millions)LoopDepth-1000 limit; recursion would crash even with correct logic

Any recursive function can technically be rewritten with a loop plus an explicit stack — but "can" isn't "should". Match the tool to the shape of the problem.

Quick drills

Drill — Countdown

🟢 L1

Print n, n−1, …, 1, one per line, then print Liftoff!. Recursively, no loops.

Show Solution
Python
def countdown(n):
    if n == 0:
        print("Liftoff!")
        return
    print(n)
    countdown(n - 1)

The print before the call makes it count down; printing after the call would count up — the position of work relative to the recursive call decides the order.

Drill — Recursive length

🟢 L1

Return the number of elements in a list, using recursion instead of a loop (using len() only to detect the end is fine).

Show Solution
Python
def length(items, i=0):
    if i == len(items):        # walked past the last element
        return 0
    return 1 + length(items, i + 1)

Each level contributes exactly 1; the base case contributes 0. Total = number of elements.


What You Learned

  • Every recursive function needs a base case (direct answer) and a recursive case that moves toward it.
  • Each call gets a frame on the call stack; the stack grows on the way down and shrinks as returns flow back up.
  • Recursion is natural for self-similar data (trees, grids, choices) and unnatural for plain linear passes.
  • Naive Fibonacci shows recursion's cost trap: correct code can still be exponential if it recomputes subproblems.
  • Python's stack is capped (~1000 frames) — missing base cases or very deep recursion raise RecursionError.
  • The leap of faith: assume the smaller call is correct and only write this level's glue.

Pattern Recognition

Think recursion when the statement says "do X to this item, then the same to the rest", when the data is nested or branching (trees, folders, mazes), or when you must enumerate all ways to choose. If the task is a flat linear pass over a known-size input, a loop is the better default.

Complexity Summary

Function (this part)TimeExtra space (stack)
factorial(n)O(n)O(n)
sum_n(n)O(n)O(n)
sum_array (index version)O(n)O(n)
find_max (index version)O(n)O(n)
reverse_string (index version)O(n²) — string buildingO(n)
naive fib(n)O(2ⁿ) — recomputationO(n)

Stack space = maximum simultaneous frames = recursion depth. It is why depth, not total calls, decides whether you crash.

Common Mistakes

  • Forgetting to return the recursive result: factorial(n - 1) instead of n * factorial(n - 1) — the answer dies in the inner call.
  • Unreachable base case: stepping the input the wrong way (n + 1) or starting where the base test can never be true.
  • Off-by-one base case: for find_max, checking i == len(nums) instead of len(nums) - 1 reads past the end.
  • Slicing in every call (nums[1:]) — quietly turns O(n) into O(n²).
  • Tracing five levels in your head instead of trusting one level — the fastest way to get lost.

Mini Quiz

Q1. What two parts does every correct recursive function need?
Base case = direct answer, recursive case = smaller self-call. Both are mandatory; the input must move toward the base.
Q2. At the deepest point, how many frames does the factorial(4) call stack hold?
factorial(4), (3), (2) and (1) are all on the stack simultaneously at the moment the base case is hit — depth 4, one frame per pending call.
Q3. sum_n(5) returns…
5 + 4 + 3 + 2 + 1 = 15. (120 is factorial(5) — different accumulator: × instead of +.)
Q4. Which statement is true?
Loops have no stack-depth problem; recursion trades stack space for elegance. And yes — loop + explicit stack can simulate any recursion (you'll do it for tree traversals in Part 16).

Practice Problems

P14.1 Sum of Digits

🟢 L1 · Understand
Problem

Return the sum of the digits of a non-negative integer n, recursively.

Input / Output

n = 4721 → 14 (4 + 7 + 2 + 1)

Key Observation

n % 10 peels off the last digit; n // 10 is the same number without it — a strictly smaller input.

Show Hint

Base case: a single-digit number is its own digit sum.

Show Approach

digit_sum(n) = last digit + digit_sum(n without its last digit). The input shrinks by a factor of 10 each call, so the base case (n == 0) is always reached.

Show Solution
Python
def digit_sum(n):
    if n == 0:
        return 0
    return n % 10 + digit_sum(n // 10)

print(digit_sum(4721))    # 14
Complexity

Time O(number of digits) · Stack O(number of digits)

Pattern Recognition
Pattern Insight

"Process one piece, then the same problem on a strictly smaller input" — the defining shape of linear recursion.

P14.2 Recursive Power

🟡 L2 · Apply
Problem

Compute x raised to a non-negative integer power n without using ** or pow().

Input / Output

x = 3, n = 4 → 81

Key Observation

xⁿ = x × xⁿ⁻¹, and anything to the power 0 is 1.

Show Hint

What is the smallest exponent, and what is its answer without any multiplication?

Show Approach

Base: power(x, 0) = 1. Recursive: x * power(x, n - 1) — n multiplications, mirroring factorial's shape.

Show Solution
Python
def power(x, n):
    if n == 0:
        return 1
    return x * power(x, n - 1)

print(power(3, 4))    # 81
Complexity

Time O(n) · Stack O(n)

Pattern Recognition
Pattern Insight

If a definition reads "value(n) builds on value(n−1)", the recursion writes itself — base case, shrink by one, combine.

P14.3 Count Occurrences

🟡 L2 · Apply
Problem

Count how many times target appears in nums, recursively.

Input / Output

nums = [1, 2, 1, 3, 1], target = 1 → 3

Key Observation

Element i contributes 1 if it matches, 0 otherwise — plus the count in the rest of the array.

Show Hint

Use an index like sum_array; the base case (past the end) contributes 0.

Show Approach

At each index add 1 if nums[i] == target else 0 to the recursive count of positions i + 1 onward.

Show Solution
Python
def count_occurrences(nums, target, i=0):
    if i == len(nums):
        return 0
    here = 1 if nums[i] == target else 0
    return here + count_occurrences(nums, target, i + 1)

print(count_occurrences([1, 2, 1, 3, 1], 1))    # 3
Complexity

Time O(n) · Stack O(n)

Pattern Recognition
Pattern Insight

"Per-element contribution + answer for the rest" generalizes far beyond counting — filters, searches and transformations all fit this skeleton.

Challenge Problem

P14-C Sum of a Nested List

🟠 L3 · Recognize
Problem

A list may contain numbers or other lists, which may themselves contain lists, to any depth. Return the sum of all numbers anywhere inside.

Input / Output

[1, [2, [3, 4]], 5] → 15

Show Hint

A loop can't know how many levels of nesting exist. What happens if your loop body meets another list… and calls the very function you're writing?

Show Approach

Walk the top list with a loop. For each item: if it's a number, add it; if it's a list, recursively sum it and add the result. The recursion depth matches the nesting depth — which no fixed number of nested loops could handle.

Show Solution
Python
def nested_sum(data):
    total = 0
    for item in data:
        if isinstance(item, list):
            total += nested_sum(item)   # a smaller copy of the same problem
        else:
            total += item
    return total

print(nested_sum([1, [2, [3, 4]], 5]))    # 15
print(nested_sum([[[10]], [], 5]))        # 15
Complexity

Time O(total number of items at all levels) · Stack O(maximum nesting depth)

Pattern Recognition
Pattern Insight

When the input is nested to an unknown depth, recursion isn't just cleaner — it's the natural tool. Loops handle one dimension; recursion handles any depth.

Revision Checklist

PART 15

Backtracking Basics

Recursion with an undo button — walk every path of choices, and cleanly back out of the ones that fail.

Part 14 taught you to trust a recursive call. Backtracking is what you build with that trust when a problem asks for all possible ways to do something: all subsets, all orderings, all paths out of a maze. You cannot write a fixed number of loops for "all subsets" — the number of nested decisions changes with the input. What you can do is make one decision, recursively handle everything after it, and then undo that decision so the next branch starts from a clean slate.

The path-of-choices mental model

Think of the last time you explored a maze. At every junction you picked a direction and walked. When the corridor ended in a wall, you didn't restart the maze from the entrance — you walked back to the last junction (undoing your last move) and tried the next direction. That is the whole idea:

Concept

Backtracking = depth-first exploration of a tree of decisions. At each step: choose an option, explore everything downstream of it, then undo the choice so the next option starts where you began.

CHOOSE
→
EXPLORE
→
UNDO

Every complete sequence of choices is one root-to-leaf path in a decision tree. "Explore all possibilities" means "visit every path in this tree" — and trees are exactly what recursion handles naturally (that's why backtracking is impossible to write cleanly with loops alone).

The decision tree for subsets of [1, 2, 3]

To build a subset, we make one decision per number: skip it (left branch) or take it (right branch). Three numbers → three levels of decisions → every leaf is one complete subset:

{}
{}
1
{}
2
1
12
{}
3
2
23
1
13
12
123
Left branch = skip the next number · right branch = take it. Leaf labels are compact: "123" means [1,2,3]; a missing digit means we skipped that number. The highlighted leaf is the path take-1 → take-2 → take-3.
The same tree, drawn with edge labels
level 0 (start):                {}
                             /       \
level 1 (decide 1):        {}          [1]
                         /    \       /     \
level 2 (decide 2):     {}     [2]   [1]    [1,2]
                       /  \    /  \  /  \    /   \
level 3 (decide 3):   {}  [3] [2] [2,3] [1] [1,3] [1,2] [1,2,3]

leaves, left → right:  8 subsets = 2³  (each of 3 numbers: in or out)

Two things to notice, because they generalize to every backtracking problem:

  • The branching factor is the number of choices at one step (here 2: skip/take). With n numbers the tree has 2ⁿ leaves — exponential, unavoidable, because 2ⁿ subsets genuinely exist.
  • A leaf is reached only when no decisions remain — that moment is your recursion's base case.
Q1. How many subsets does [1, 2, 3, 4] have, and how many leaves does its decision tree have?
Each of the 4 numbers doubles the possibilities: 2⁴ = 16. The decision tree has one leaf per subset.

The choose → explore → undo template

Almost every beginner backtracking solution is this skeleton with different state and choices:

Python — generic template
def backtrack(path, start):
    if goal_reached(path):            # base case: a full path
        record(path[:])               # store a COPY of the path
        return
    for choice in options(start):     # every option at this junction
        path.append(choice)           # CHOOSE  — state changes
        backtrack(path, next_start)   # EXPLORE — dive deeper
        path.pop()                    # UNDO    — state restored
Python — the three lines that matter
path.append(nums[i])   # CHOOSE:  "I take this option for now"
backtrack(...)         # EXPLORE: "everything downstream of that choice"
path.pop()             # UNDO:    "I un-take it, so the next branch
                       #           starts exactly where I did"

The undo is what keeps sibling branches independent. After pop(), the state is byte-for-byte identical to before the append — so the for loop's next option is explored from a clean slate, never polluted by the previous branch.

Buggy subsets — the pop deleted
for i in range(start, len(nums)):
    path.append(nums[i])
    backtrack(i + 1)
    # path.pop()   ← forgotten!

The state leaks between branches: choices from earlier siblings are still in path when the next branch records it. For [1, 2, 3] you'd record:

Output comparison
without undo ❌  [[], [1], [1,2], [1,2,3], [1,2,3,3], [1,2,3,3,2], [1,2,3,3,2,3], [1,2,3,3,2,3,3]]
with undo    ✅  [[], [1], [1,2], [1,2,3], [1,3],   [2],   [2,3],   [3]]
Q2. What exactly does the undo step (path.pop()) accomplish?
Undo only touches the working path. Recorded results stay; the next branch of the tree begins from the exact state this branch began from.

Worked problem 1 — Generate All Subsets

15.1 Subsets (the Power Set)

🟡 L2 · Apply
Problem

Given a list of distinct numbers, return every subset (the "power set"). Order of subsets and of numbers inside them can be any consistent order.

Input / Output

nums = [1, 2, 3] → [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]] — 8 subsets

Key Observation

A subset is fully described by a sequence of in/out decisions, one per element. Decision trees of choices are recursion's home turf (see the tree above).

Brute Force

Try to write loops: to enumerate all subsets you'd need n nested loops (one in/out decision each) — and n changes with the input. Fixed loops can't express "a number of decisions known only at runtime". That dead end is exactly what recursion removes.

Optimal Idea

Walk the "choose the next number" decision tree. Keep a path of numbers chosen so far; from position start, try every remaining number as the next pick. Every node of this tree is a valid subset, so record the path at entry, not just at leaves.

Python Solution
Python
def subsets(nums):
    result = []
    path = []                       # numbers chosen so far

    def backtrack(start):
        result.append(path[:])      # every state of path is a real subset
        for i in range(start, len(nums)):
            path.append(nums[i])    # CHOOSE  nums[i] as the next element
            backtrack(i + 1)        # EXPLORE only numbers AFTER i (no repeats)
            path.pop()              # UNDO — clean slate for the next choice

    backtrack(0)
    return result

print(subsets([1, 2, 3]))
# [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
Dry Run
Dry Run — nums = [1, 2, 3], path state at every step
backtrack(0)  record []                result = [[]]
  i=0  choose 1 → path = [1]
  backtrack(1)  record [1]             result = [[], [1]]
    i=1  choose 2 → path = [1, 2]
    backtrack(2)  record [1,2]         result += [1,2]
      i=2  choose 3 → path = [1,2,3]
      backtrack(3)  record [1,2,3]     result += [1,2,3]
      no numbers left → return
      undo → path = [1,2]   undo → path = [1]
    i=2  choose 3 → path = [1,3]
    backtrack(3)  record [1,3]         result += [1,3]
      undo → path = [1]    undo → path = []
  i=1  choose 2 → path = [2]
  backtrack(2)  record [2]             result += [2]
    i=2  choose 3 → path = [2,3]
    backtrack(3)  record [2,3]         result += [2,3]
      undo → path = [2]    undo → path = []
  i=2  choose 3 → path = [3]
  backtrack(3)  record [3]             result += [3]
    undo → path = []
✅ result = [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

Watch the path column in your head: it grows by one on every choose and shrinks by one on every undo — the stack from Part 14, but now we also manipulate shared state between calls.

Complexity

Time O(n · 2ⁿ) — 2ⁿ subsets, each copied at cost up to n · Space O(n) for path + recursion depth (excluding the output).

Edge Cases
  • nums = [] → [[]]: one subset, the empty one — the base case fires immediately.
  • Single element [7] → [[], [7]].
Common Mistakes
  • result.append(path) instead of path[:] — you store a reference to the same list, which keeps changing; at the end every recorded subset is [].
  • Forgetting path.pop() (see the "If you forget UNDO" tab above).
  • Calling backtrack(i) instead of backtrack(i + 1) — the same number gets reused, generating subsets with repeats like [1,1].
Common Mistake — the reference trap

path is one shared list that you mutate thousands of times. result.append(path) stores a pointer to it, not a snapshot — when the run ends, every entry reflects the (now empty) final path. Always record a copy: path[:] or list(path).

Pattern Recognition
Pattern Insight

"Generate all subsets / combinations / arrangements" ⇒ a decision tree + choose–explore–undo. The branching factor is the number of options per step; the base case is "no decisions left".

Worked problem 2 — Generate All Permutations

15.2 Permutations

🟡 L2 · Apply
Problem

Given distinct numbers, return every possible ordering.

Input / Output

[1, 2, 3] → [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]] — 3! = 6 orderings

Key Observation

Now every unused number is a legal next choice — order matters, so unlike subsets we don't skip ahead with start. We need to remember which numbers are already in the path: a used flag per element.

Python Solution
Python
def permutations(nums):
    result = []
    path = []
    used = [False] * len(nums)      # used[i] = nums[i] already placed?

    def backtrack():
        if len(path) == len(nums):  # no decisions left → full ordering
            result.append(path[:])
            return
        for i in range(len(nums)):
            if used[i]:
                continue            # not a legal choice right now
            used[i] = True
            path.append(nums[i])    # CHOOSE
            backtrack()             # EXPLORE
            path.pop()              # UNDO the path …
            used[i] = False         # … AND the marker: two changes, two undos

    backtrack()
    return result
Dry Run
Dry Run — [1, 2, 3] (first branch fully, then the pattern repeats)
backtrack()  path=[] used=[F,F,F]
  i=0 choose 1 → path=[1]     used=[T,F,F]
    i=1 choose 2 → path=[1,2]   used=[T,T,F]
      i=2 choose 3 → path=[1,2,3] → full ✅ record [1,2,3]
      undo 3 → undo 2 → path=[1], used=[T,F,F]
    i=2 choose 3 → path=[1,3]
      i=1 choose 2 → path=[1,3,2] ✅ record [1,3,2]
      undo 2 → undo 3
    undo 1
  i=1 choose 2 → path=[2] … produces [2,1,3], [2,3,1]
  i=2 choose 3 → path=[3] … produces [3,1,2], [3,2,1]
✅ 6 permutations, exactly the 3! orderings

Note the discipline: every choose that changes two things (path and used) needs two undos. Change and restore must be perfect mirrors.

Complexity

Time O(n · n!) — n! orderings, each copied at cost n · Space O(n) for path/used/depth.

Pattern Recognition
Pattern Insight

"All orderings / arrangements" ⇒ the subsets skeleton minus the start index, plus a used marker. The undo list must mirror the choose list exactly.

Worked problem 3 — Combinations

15.3 Combinations of k numbers from 1..n

🟡 L2 · Apply
Problem

Return every way to choose k numbers from 1…n, order irrelevant.

Input / Output

n = 4, k = 2 → [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]] — C(4,2) = 6

Key Observation

"Order irrelevant" means [1,2] and [2,1] are the same combination — so we forbid going backwards. Picking in increasing order (always choose a number larger than the last) generates each combination exactly once. That is what passing i + 1 as the next start does.

Python Solution
Python
def combine(n, k):
    result = []
    path = []

    def backtrack(start):
        if len(path) == k:          # chose k numbers → complete
            result.append(path[:])
            return
        for i in range(start, n + 1):
            path.append(i)          # CHOOSE i (always bigger than the last)
            backtrack(i + 1)        # EXPLORE — only larger numbers remain
            path.pop()              # UNDO

    backtrack(1)
    return result
Dry Run
Dry Run — combine(4, 2)
backtrack(1)  path=[]
  i=1 choose 1 → path=[1]
    backtrack(2): i=2 → [1,2] ✅ · i=3 → [1,3] ✅ · i=4 → [1,4] ✅
    undo → path=[]
  i=2 choose 2 → path=[2]
    backtrack(3): i=3 → [2,3] ✅ · i=4 → [2,4] ✅
    undo → path=[]
  i=3 choose 3 → path=[3]
    backtrack(4): i=4 → [3,4] ✅
    undo → path=[]
  i=4 choose 4 → path=[4] → backtrack(5) has no numbers and k=2 not reached → nothing recorded
✅ 6 combinations, each in increasing order, none repeated
Complexity

Time O(C(n,k) · k) · Space O(k) for path + depth.

Pattern Recognition
Pattern Insight

Subsets = combinations of every size. "Pick k, order doesn't matter" is subsets with a size check (len(path) == k) and the increasing-order rule. One skeleton, three problems.


Seeing undo in action: maze path existence

Backtracking is not only for generating things. Here is the same triangle — choose, explore, undo — solving "can I get from S to E?" on a grid, moving up/down/left/right, walls marked #:

The maze
        col 0   1   2   3
row 0     S    .   .   #
row 1     .    #   #   .
row 2     .    .   .   E

S = start · E = exit · # = wall · . = open cell
moves allowed: right, down, left, up (one cell at a time)

The recursive question: dfs(r, c) = "from cell (r, c), can I reach E?" Its answer is "yes if any of my four neighbors can" — the same problem, one step smaller, exactly the trust-the-recursion shape from Part 14. The choose step is stepping onto a cell (marking it visited); the undo step is un-marking it when all routes from it failed.

Python
def path_exists(grid):
    rows, cols = len(grid), len(grid[0])
    visited = set()                 # cells in the current exploration

    def dfs(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols:
            return False            # outside the maze
        if grid[r][c] == '#' or (r, c) in visited:
            return False            # wall, or already tried from here
        if grid[r][c] == 'E':
            return True             # reached the exit
        visited.add((r, c))         # CHOOSE: step here
        found = (dfs(r, c + 1) or   # try right
                 dfs(r + 1, c) or   # then down
                 dfs(r, c - 1) or   # then left
                 dfs(r - 1, c))     # then up
        visited.remove((r, c))      # UNDO: step back off it
        return found

    return dfs(0, 0)
Dry Run — watch the dead end trigger the undo
step on (0,0) S            visited = {(0,0)}
  right → (0,1) open       visited = {(0,0),(0,1)}
    right → (0,2) open     visited = {…,(0,2)}
      right → (0,3) is #          ❌ wall
      down  → (1,2) is #          ❌ wall
      left  → (0,1) in visited    ❌ would loop forever without this check
      up    → outside the grid    ❌
      all four failed → UNDO: remove (0,2) → report False back
    down  → (1,1) is #            ❌ wall
    left  → (0,0) visited         ❌
    up    → outside               ❌
    all four failed → UNDO: remove (0,1) → report False back
  (back at start, right is exhausted)
  down → (1,0) open               visited = {(0,0),(1,0)}
    right → (1,1) is #            ❌
    down  → (2,0) open
      right → (2,1) open
        right → (2,2) open
          right → (2,3) is E      ✅ FOUND
path taken: (0,0) → (1,0) → (2,0) → (2,1) → (2,2) → (2,3)

Two honest notes. First, the visited check does double duty: it prevents infinite loops around open cells and is our chosen-state marker. Second, for a pure yes/no answer, un-marking isn't strictly required (a cell that led nowhere once will lead nowhere again) — but keeping the undo makes the function reusable when you later want to collect the actual path, and it is the backtracking discipline you should practice here.

Optional — the idea behind N-Queens Optional

Concept — N-Queens in one paragraph

Place n chess queens on an n×n board so that no two attack each other. The backtracking shape: put one queen per row; in each row try each column; if the placement clashes with an earlier queen (same column or diagonal), skip it — that branch is already illegal, so we drop it instead of exploring below it, a habit called pruning obvious dead ends. Otherwise place → recurse into the next row → remove the queen (undo). For n = 4 the exploration finds:

A 4-queens solution
.  Q  .  .
.  .  .  Q
Q  .  .  .
.  .  Q  .

Full code is a satisfying exercise once Part 15's template feels automatic — the state is "queens placed so far", the choices are "column in this row", the undo is "remove the queen".

Spotting backtracking in a problem statement

The statement says…You think…
"generate all subsets / all combinations"decision tree, in/out choices, record every node
"all permutations / all orderings"same tree + used markers
"is there any way to reach…?"explore choices; dead end → undo and try the next
"the smallest set of choices that works" (later)backtracking first, optimization later

What You Learned

  • Backtracking = depth-first exploration of a decision tree: choose → explore → undo at every junction.
  • The undo (path.pop() and un-marking flags) restores the state so sibling branches start clean.
  • Subsets, permutations and combinations are one skeleton with small variations: start index, size check, used markers.
  • Record copies (path[:]) — the path itself is shared and mutated.
  • Grid exploration (mazes) uses the same triangle: step in as "choose", step back as "undo", visited-set prevents loops.

Pattern Recognition

The statement asks for all possibilities ("every subset", "all orderings", "any path") and no fixed number of loops can express the varying depth of decisions. That combination — exponential possibilities + runtime-decided depth — is the backtracking signature. Brute force by loops dies here; choose–explore–undo handles it in ~15 lines.

Complexity Summary

ProblemOutput sizeTimeExtra space
Subsets of n elements2ⁿO(n · 2ⁿ)O(n)
Permutations of n elementsn!O(n · n!)O(n)
Combinations C(n,k)C(n,k)O(C(n,k) · k)O(k)
Maze path existence (R×C)yes/noO(R · C) with permanent visited marks; the undo variant may re-explore cells via different pathsO(R · C)

For generators, the exponential factor is honest work — the output really is that big. Space excluding output stays small because undo reuses one path.

Common Mistakes

  • Missing undo — state leaks into sibling branches and results get polluted or duplicated.
  • Incomplete undo — choosing changed two things (path + used) but you pop only one. Undo must mirror choose exactly.
  • Appending path instead of path[:] — every recorded subset ends up empty (they all alias one mutated list).
  • Reusing choices — backtrack(i) instead of backtrack(i + 1) in combinations/subsets produces repeats like [1,1].
  • No base case — for generators the base case is "path complete"; without it, recursion never records and never stops cleanly.

Mini Quiz

Q1. The three moves of backtracking, in order, are…
Pick an option, recursively handle everything after it, then revert the pick before trying the next option.
Q2. Why result.append(path[:]) and not result.append(path)?
The path list keeps changing as branches explore and undo. Storing it directly stores a reference — all entries end up aliasing one final (empty) list.
Q3. How many permutations does [1, 2, 3, 4] have?
4 × 3 × 2 × 1 = 4! = 24. The decision tree's first level has 4 branches, then 3, then 2, then 1.
Q4. In permutations, forgetting used[i] = False (but keeping path.pop()) causes…
Two changes happened at choose-time (append + mark), so two undos are needed. A stale True marker hides legal choices from sibling branches.

Practice Problems

P15.1 All Binary Strings of Length n

🟢 L1 · Understand
Problem

Generate every string of n bits. For n = 2: ["00", "01", "10", "11"].

Key Observation

Each position has exactly 2 choices — a decision tree of depth n with 2ⁿ leaves. The subsets skeleton with no start needed: every position must be filled.

Show Hint

Base case: path holds n characters. Loop over the two options "0" and "1".

Show Approach

Keep path as a list of characters; at each level append '0', recurse, pop, append '1', recurse, pop. Join on the base case.

Show Solution
Python
def binary_strings(n):
    result = []
    path = []

    def backtrack():
        if len(path) == n:
            result.append("".join(path))
            return
        for bit in "01":
            path.append(bit)     # CHOOSE
            backtrack()          # EXPLORE
            path.pop()           # UNDO

    backtrack()
    return result

print(binary_strings(2))    # ['00', '01', '10', '11']
Complexity

Time O(n · 2ⁿ) · Space O(n)

Pattern Recognition
Pattern Insight

Fixed number of positions, small option set per position ⇒ the plainest choose–explore–undo loop. If you can write this one from memory, subsets and permutations are small edits away.

P15.2 Subsets That Sum to a Target

🟡 L2 · Apply
Problem

Given distinct positive numbers, return all subsets whose elements sum to target.

Input / Output

nums = [1, 2, 3, 4], target = 5 → [[1, 4], [2, 3]]

Key Observation

Carry the running sum as part of the state. With positive numbers, once the sum hits the target you can record and stop that branch — deeper choices only overshoot.

Show Hint

Take the combinations skeleton (increasing start) and pass total down; record when total == target.

Show Approach

backtrack(start, total): if total == target, record path[:] and return. Otherwise for each i from start: append, recurse with total + nums[i], pop.

Show Solution
Python
def subsets_with_sum(nums, target):
    result = []
    path = []

    def backtrack(start, total):
        if total == target:
            result.append(path[:])
            return
        for i in range(start, len(nums)):
            path.append(nums[i])                 # CHOOSE
            backtrack(i + 1, total + nums[i])    # EXPLORE
            path.pop()                           # UNDO

    backtrack(0, 0)
    return result

print(subsets_with_sum([1, 2, 3, 4], 5))    # [[1, 4], [2, 3]]
Complexity

Time O(2ⁿ) paths explored (output-sized in the worst case) · Space O(n)

Pattern Recognition
Pattern Insight

When a constraint ("sum equals target") can be checked while building the path, carry it as an extra argument and stop the branch the moment it's satisfied or broken — exploring fewer nodes than the full tree.

P15.3 Permutations of a String

🟡 L2 · Apply
Problem

Return all permutations of the characters of a string with distinct letters. "abc" → 6 strings.

Key Observation

Identical to 15.2's skeleton — the "numbers" are just characters, joined into a string at the base case.

Show Hint

used as a list of booleans over indices; join with "".join(path).

Show Approach

Copy the permutations function; replace the record line with a string join.

Show Solution
Python
def string_perms(s):
    result = []
    path = []
    used = [False] * len(s)

    def backtrack():
        if len(path) == len(s):
            result.append("".join(path))
            return
        for i in range(len(s)):
            if used[i]:
                continue
            used[i] = True
            path.append(s[i])
            backtrack()
            path.pop()
            used[i] = False

    backtrack()
    return result

print(string_perms("abc"))
# ['abc', 'acb', 'bac', 'bca', 'cab', 'cba']
Complexity

Time O(n · n!) · Space O(n)

Pattern Recognition
Pattern Insight

The permutation skeleton is type-agnostic — numbers, letters, tiles. Learn the shape once and the "container" becomes a detail.

Challenge Problem

P15-C Generate All Balanced Parentheses

🟠 L3 · Recognize
Problem

Given n pairs, generate every string of parentheses that is balanced. For n = 2: ["(())", "()()"].

Key Observation

Two choices per step — add "(" or add ")" — but not always legal: you may open only if you still have opens left, and close only if it won't outnumber opens. The legality check replaces the used marker.

Show Hint

Track two counters: opens used, closes used. When is "(" legal? When is ")" legal? When is the string complete?

Show Approach

backtrack(open_used, close_used): if the string reaches length 2n, record. If open_used < n, try "(". If close_used < open_used, try ")". Each try is choose–explore–undo.

Show Solution
Python
def generate_parentheses(n):
    result = []
    path = []

    def backtrack(open_used, close_used):
        if len(path) == 2 * n:
            result.append("".join(path))
            return
        if open_used < n:            # room to open another bracket
            path.append("(")
            backtrack(open_used + 1, close_used)
            path.pop()
        if close_used < open_used:   # a close must match an open one
            path.append(")")
            backtrack(open_used, close_used + 1)
            path.pop()

    backtrack(0, 0)
    return result

print(generate_parentheses(2))    # ['(())', '()()']
print(generate_parentheses(3))    # 5 strings, e.g. '((()))', '(()())', …
Complexity

Time O(4ⁿ / √n) Catalan-number growth — every generated string is valid, nothing wasted · Space O(n)

Pattern Recognition
Pattern Insight

When each choice has a rule rather than a simple marker, encode the rule as an if before recursing — illegal branches are never even entered. The tree stays exactly as big as the set of valid answers.

Revision Checklist

PART 16

Trees

Your first non-linear structure — where Part 14's "trust the recursion" stops being a technique and becomes the only sane way to think.

Arrays and lists are linear: every element has one neighbor before and one after. But file systems, company org charts, HTML documents, and AI game moves are hierarchical — one thing contains many things, which contain more things. The data structure for that shape is the tree. And trees explain why you just learned recursion: a tree is defined as "a root plus smaller trees", so nearly every tree algorithm is three lines of recursion that would be a nightmare of bookkeeping with loops.

What exactly is a tree?

Concept

A tree is a collection of nodes connected by edges with three rules: it is connected (everything reachable from the top), there are no cycles (no loops back), and there is exactly one root — a special node with no parent. A binary tree is the version where each node has at most two children, called left and right.

Our example tree — used for the whole first half of this part
            (1)              ← root: the one node with no parent
           /   \
       (2)       (3)          ← 2 and 3 are children of 1; 1 is their parent
      /   \        \
   (4)     (5)      (6)       ← 4, 5, 6 have no children → leaves
1
2
3
4
5
6
The same tree as a picture. Careful: node 3 has no left child — a drawing can't show a missing slot, but the ASCII above (and the code below) makes it explicit.

The vocabulary you need (and only this much)

TermMeaningIn our tree
Rootthe top node, no parent1
Parent / Childa node points to its children; they point back conceptually2's children are 4 and 5
Leafa node with zero children4, 5, 6
Edgethe link between parent and child5 edges for 6 nodes (always n − 1)
Subtreeany node plus everything below it — a smaller treesubtree rooted at 2 = {2, 4, 5}
Depthdistance (in edges) from the root to a node; root has depth 0depth of 6 is 2
Heightthe longest root-to-leaf path, counted in nodes here; empty tree = 0, single node = 1height of the tree is 3
Binary treeevery node has at most 2 childrenour whole example

Some books count height in edges (our tree would be "height 2"). Both conventions exist — pick one, state it, stay consistent. This book counts nodes.

Building a binary tree in Python

A tree node is just an object with a value and two pointers. A missing child is None — that's the entire representation:

Python
class TreeNode:
    def __init__(self, val):
        self.val = val
        self.left = None      # left child (or None)
        self.right = None     # right child (or None)

# Build our example tree by hand:
root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)     # 2's left child
root.left.right = TreeNode(5)    # 2's right child
root.right.right = TreeNode(6)   # 3 has NO left child — we never set it

From here on, every problem in this part is solved the same way: ask "if I knew the answer for the left subtree and the right subtree — both smaller trees — how do I get the answer for the whole tree?" That question, plus the base case None → something sensible, solves all nine problems below.

Traversals — visiting every node in an agreed order

With an array, "visit everything" has one obvious order: first to last. A tree branches, so there are several useful orders. The three depth-first traversals differ only in when the root of each subtree is visited relative to its two subtrees:

  • Preorder — root first: visit node → left → right
  • Inorder — root in the middle: left → visit node → right
  • Postorder — root last: left → right → visit node

The fourth, level-order, abandons depth: it visits the root, then level 2 left-to-right, then level 3 — like reading the tree row by row.

Preorder (root → left → right)

Python — recursive
def preorder(node, out):
    if node is None:            # base case: nothing to visit
        return
    out.append(node.val)        # 1. root first
    preorder(node.left, out)    # 2. then the whole left subtree
    preorder(node.right, out)   # 3. then the whole right subtree
Dry Run — preorder on our example tree
visit 1 → out = [1]            (root of the whole tree, FIRST)
  preorder(2):  visit 2 → out = [1, 2]
    preorder(4): visit 4 → out = [1, 2, 4]      (leaf)
    preorder(5): visit 5 → out = [1, 2, 4, 5]   (leaf)
  preorder(3):  visit 3 → out = [1, 2, 4, 5, 3]
    preorder(None): 3 has no left child → nothing
    preorder(6): visit 6 → out = [1, 2, 4, 5, 3, 6] ✅

Output: 1 2 4 5 3 6. Preorder is the "I meet the parent before its descendants" order — the order you'd use to copy or serialize a tree.

Python — iterative with an explicit stack
def preorder_iterative(root):
    out = []
    stack = [root] if root else []    # the call stack, made visible
    while stack:
        node = stack.pop()            # take the most recent node (LIFO)
        out.append(node.val)
        if node.right:                # push RIGHT first…
            stack.append(node.right)
        if node.left:                 # …so LEFT pops first (processed first)
            stack.append(node.left)
    return out                        # [1, 2, 4, 5, 3, 6]

Why the reversed push? A stack is last-in-first-out. We want to process left before right, so right must be pushed first. This is literally the Part 14 insight — "any recursion can be rewritten as a loop plus an explicit stack" — made concrete.

Inorder (left → root → right)

Python — recursive
def inorder(node, out):
    if node is None:
        return
    inorder(node.left, out)     # 1. entire left subtree
    out.append(node.val)        # 2. THEN the node itself
    inorder(node.right, out)    # 3. then the entire right subtree

Output on our tree: 4 2 5 1 3 6 — each node appears after everything in its left subtree and before everything in its right. Keep that sentence in mind; it becomes a superpower in the BST section below.

Python — iterative with an explicit stack
def inorder_iterative(root):
    out = []
    stack = []
    node = root
    while node or stack:
        while node:               # slide as far left as possible,
            stack.append(node)    # remembering every node we pass
            node = node.left
        node = stack.pop()        # leftmost unvisited node → visit it
        out.append(node.val)
        node = node.right         # then give its right subtree the same treatment
    return out                    # [4, 2, 5, 1, 3, 6]
Dry Run — inorder_iterative on our example (key moments)
slide left from 1: push 1, push 2, push 4   (4.left = None → stop slide)
pop 4 → visit → out = [4]          (leftmost node done)
4.right = None → pop 2 → visit → out = [4, 2]    (2's left is done)
2.right = 5 → slide: push 5 → pop 5 → out = [4, 2, 5]
5.right = None → pop 1 → visit → out = [4, 2, 5, 1]   (left HALF finished)
1.right = 3 → 3 has no left → pop 3 → out = [4, 2, 5, 1, 3]
3.right = 6 → push 6 → pop 6 → out = [4, 2, 5, 1, 3, 6] ✅

Postorder (left → right → root)

Python — recursive
def postorder(node, out):
    if node is None:
        return
    postorder(node.left, out)   # 1. left subtree first
    postorder(node.right, out)  # 2. then right subtree
    out.append(node.val)        # 3. root LAST — children finished before parent

Output on our tree: 4 5 2 6 3 1. Postorder is the "delete/measure children before the parent" order — e.g. freeing a tree safely, or computing "total size of folder including subfolders".

Python — iterative, via a clever reversal
def postorder_iterative(root):
    out = []
    stack = [root] if root else []
    while stack:
        node = stack.pop()
        out.append(node.val)
        if node.left:               # push left first, then right,
            stack.append(node.left) # so RIGHT pops first
        if node.right:
            stack.append(node.right)
    out.reverse()                   # root-first right-to-left → root-last
    return out                      # was [1, 3, 6, 2, 5, 4] → now [4, 5, 2, 6, 3, 1]

The trick: visit root → right → left (a preorder with children swapped) and then reverse the whole output. Mirror image of preorder, flipped, = postorder. Easier to trust than a direct three-state iterative postorder, which is famously fiddly.

Level-order (breadth-first, with a queue)

Depth-first uses a stack (of pending ancestors). Level-order wants the oldest discovered node first — that's a queue (first-in-first-out):

Python — iterative with a queue
from collections import deque

def level_order(root):
    if root is None:
        return []
    out = []
    queue = deque([root])
    while queue:
        node = queue.popleft()      # oldest discovered node
        out.append(node.val)
        if node.left:
            queue.append(node.left)   # children discovered for later
        if node.right:
            queue.append(node.right)
    return out                        # [1, 2, 3, 4, 5, 6]
Dry Run — level-order queue on our example
queue = [1]
pop 1 → out = [1] · enqueue 2, 3 → queue = [2, 3]
pop 2 → out = [1, 2] · enqueue 4, 5 → queue = [3, 4, 5]
pop 3 → out = [1, 2, 3] · enqueue 6 (no left child) → queue = [4, 5, 6]
pop 4 → out = [1, 2, 3, 4] · queue = [5, 6]
pop 5 → out = [1, 2, 3, 4, 5] · queue = [6]
pop 6 → out = [1, 2, 3, 4, 5, 6] · queue = [] ✅

One tree, four orders

TraversalRuleOutput on our treeTypical use
Preorderroot → left → right1 2 4 5 3 6copy / serialize a tree
Inorderleft → root → right4 2 5 1 3 6sorted order in a BST (below!)
Postorderleft → right → root4 5 2 6 3 1delete, measure bottom-up
Level-orderlevel by level1 2 3 4 5 6shortest structure, per-level work
Q1. Which traversal visits a subtree's root after both of its subtrees?
"Post" = after: left subtree, right subtree, then the node itself. Pre = before, in = in between.
Q2. Level-order traversal needs which data structure, and why?
Level 2 must finish before level 3 begins. Nodes of level 2 are discovered while level 1 is processed — a FIFO queue preserves exactly that order. Stacks invert it.

Problems — the "know two subtrees, combine" family

Every solution below is two moves: a base case for None, and a combination step. Read the code by asking only "what does this level contribute?" — not by tracing the whole recursion (Part 14's leap of faith).

16.1 Count the Nodes

🟢 L1 · Understand
Problem

Return how many nodes a binary tree contains.

Input / Output

our example tree → 6

Key Observation

Total nodes = this node (1) + nodes in left subtree + nodes in right subtree. The empty tree has 0.

Python Solution
Python
def count_nodes(root):
    if root is None:                # base case: empty subtree
        return 0
    return 1 + count_nodes(root.left) + count_nodes(root.right)
Dry Run
Dry Run — count on our example tree
count(1) waits for: 1 + count(2) + count(3)
  count(2) waits for: 1 + count(4) + count(5)
    count(4) → leaf → 1
    count(5) → leaf → 1
  count(2) = 1 + 1 + 1 = 3
  count(3) waits for: 1 + count(None) + count(6)
    count(None) → 0        ← the base case doing its job
    count(6) → leaf → 1
  count(3) = 1 + 0 + 1 = 2
count(1) = 1 + 3 + 2 = 6 ✅
Complexity

Time O(n) — every node visited once · Space O(h) stack, h = height

Pattern Recognition
Pattern Insight

"Whole = this node's piece + left answer + right answer" is THE generic tree-recursion shape. Sum of values, count of leaves, count of even values — all the same function with a different combine step.

16.2 Height / Maximum Depth

🟡 L2 · Apply
Problem

Return the height of the tree: the number of nodes on the longest root-to-leaf path. Empty tree → 0, single node → 1.

Input / Output

our example tree → 3 (path 1 → 2 → 4)

Key Observation

The longest path from the root must go through one of its two children. So: height = 1 (this node) + the bigger of the two children's heights.

Python Solution
Python
def height(root):
    if root is None:
        return 0                       # empty tree contributes 0 levels
    return 1 + max(height(root.left), height(root.right))
Dry Run
Dry Run — height on our example tree
height(6) → 1 + max(0, 0) = 1                 (leaf)
height(3) → 1 + max(height(None)=0, height(6)=1) = 2
height(4) → 1 · height(5) → 1                 (leaves)
height(2) → 1 + max(1, 1) = 2
height(1) → 1 + max(2, 2) = 3 ✅
Edge Cases
  • Empty tree → 0 (the base case is the definition).
  • Conventions differ: counting edges would give 2 for this tree. Check which one a given task wants — the code differs by one line.
Complexity

Time O(n) · Space O(h)

Pattern Recognition
Pattern Insight

Whenever the statement says "longest / deepest path", the combine step is max over children; "shortest" becomes min — but read problem 16.9 before trusting min blindly.

16.3 Level-Order Traversal, Level by Level

🟡 L2 · Apply
Problem

Return the node values grouped per level: our example tree → [[1], [2, 3], [4, 5, 6]].

Key Observation

The plain queue from above mixes levels. But at the top of each while-loop iteration, the queue contains exactly one level's worth of nodes. Freeze that count, pop exactly that many, and push only their children — the next round is then exactly the next level.

Python Solution
Python
from collections import deque

def level_order_levels(root):
    if root is None:
        return []
    result = []
    queue = deque([root])
    while queue:
        level_size = len(queue)      # freeze: this many nodes = this level
        level = []
        for _ in range(level_size):
            node = queue.popleft()
            level.append(node.val)
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        result.append(level)         # one fully collected level
    return result
Dry Run
Dry Run — our example tree
round 1: level_size = 1 (queue=[1])    → level [1] · enqueue 2, 3
round 2: level_size = 2 (queue=[2,3])  → level [2, 3] · enqueue 4, 5, then 6
round 3: level_size = 3 (queue=[4,5,6]) → level [4, 5, 6] · nothing to enqueue
queue empty → [[1], [2, 3], [4, 5, 6]] ✅
Complexity

Time O(n) — every node enters and leaves the queue once · Space O(w), the widest level

Pattern Recognition
Pattern Insight

"Freeze the level size" is the standard trick for any per-level tree question — rightmost node of each row, average per row, zigzag order. It reappears in this part's challenge problem.

Five compact classics

Same family, shown compactly. For each: read the base case and the combine line — that is the whole algorithm.

16.4 Find the Maximum Value

🟢 L1
Problem

Return the largest value stored anywhere in the tree (our example → 6).

Key Observation

The max is the bigger of: this node's value, the left subtree's max, the right subtree's max.

Python
def find_max(node):
    if node is None:
        return float('-inf')     # neutral for max: loses to any real value
    best_of_children = max(find_max(node.left), find_max(node.right))
    return max(node.val, best_of_children)

# our example tree → 6

The base case returns neutral element — negative infinity for a max (positive infinity would be the neutral element for a min). Choosing the right neutral element is the only design decision here.

Complexity

Time O(n) · Space O(h)

Pattern Recognition
Pattern Insight

Exactly 16.1 with + replaced by max. Spotting that mapping between problems is the actual skill this part trains.

16.5 Search for a Value

🟢 L1
Problem

Return True if any node holds target.

Key Observation

Found here — or found in the left subtree — or found in the right. A perfect or.

Python
def contains(node, target):
    if node is None:
        return False                       # dead end: not here
    if node.val == target:
        return True                        # found — no need to look further
    return contains(node.left, target) or contains(node.right, target)

# contains(our_tree, 5) → True · contains(our_tree, 9) → False

The or short-circuits: if the left subtree finds it, the right subtree is never searched. Worst case (absent target) still visits everything.

Complexity

Time O(n) worst case · Space O(h)

Pattern Recognition
Pattern Insight

Found anywhere ⇒ boolean or of subtree answers. On an unordered tree this O(n) scan is the best possible — until you see what a BST does to it below.

16.6 Sum of All Nodes

🟢 L1
Problem

Add up every value in the tree.

Key Observation

Total = this value + left subtree's total + right subtree's total.

Python
def tree_sum(node):
    if node is None:
        return 0
    return node.val + tree_sum(node.left) + tree_sum(node.right)

# our example tree → 1+2+3+4+5+6 = 21
Complexity

Time O(n) · Space O(h)

Pattern Recognition
Pattern Insight

Notice all three base cases so far: 0 for counting/summing, −∞ for max. Empty subtree = "contribute nothing" — translate "nothing" into the right value per problem.

16.7 Check If Two Trees Are Identical

🟡 L2
Problem

Given roots p and q, return whether the two trees have identical shape and values.

Key Observation

Identical ⇔ same root value and identical left subtrees and identical right subtrees. Two base cases: both None → equal; exactly one None → not.

Python
def is_same(p, q):
    if p is None and q is None:
        return True                        # both empty → match
    if p is None or q is None:
        return False                       # exactly one empty → mismatch
    return (p.val == q.val
            and is_same(p.left, q.left)
            and is_same(p.right, q.right))

Two None checks are needed and their order matters: if both are None we must return True before the second check would misreport it as False.

Complexity

Time O(min(n, m)) — stops at the first mismatch · Space O(height of the smaller)

Pattern Recognition
Pattern Insight

Two-tree problems walk both trees in lockstep: recurse on (left, left) and (right, right). Mirrored comparison — a variant worth knowing — compares (left, right) instead.

16.8 Invert (Mirror) a Binary Tree

🟡 L2
Problem

Swap every node's two children so the whole tree becomes its mirror image (in place).

Python
def invert(node):
    if node is None:
        return None
    left_done = invert(node.left)      # fully mirror both subtrees FIRST
    right_done = invert(node.right)
    node.left, node.right = right_done, left_done   # then swap them here
    return node
Dry Run — inverting our example tree
leaves 4, 5, 6: swapping two Nones → unchanged
node 2: left←5, right←4          → subtree: 2 / 5 4
node 3: left←6, right←None       → subtree: 3 / 6 ·
node 1: left←3-subtree, right←2-subtree

result:        1
             /   \
            3     2
           /     / \
          6     5   4
level-order after: [1, 3, 2, 6, 5, 4] ✅ every left/right pair is mirrored
Complexity

Time O(n) · Space O(h)

Pattern Recognition
Pattern Insight

Mutating trees recursively is safe as long as you grab references before overwriting (left_done/right_done first, swap second). Swap-then-recurse also works but is easier to get wrong.

16.9 Minimum Depth — a trap worth learning

🟡 L2
Problem

Depth of the shortest path from root to a leaf.

Key Observation (the trap)

You cannot just change max to min. Consider a chain 1 → 3 → 6 going only right: node 1 has left = None, and naive 1 + min(0, 2) answers 1 — but None is not a leaf, it's a missing child. A leaf path must end at a real leaf, so missing children must be ignored, not treated as depth 0.

Python Solution
Python
def min_depth(node):
    if node is None:
        return 0
    if node.left is None:              # only a right side exists — follow it
        return 1 + min_depth(node.right)
    if node.right is None:             # only a left side exists
        return 1 + min_depth(node.left)
    return 1 + min(min_depth(node.left), min_depth(node.right))

# our example tree → 1 + min(2, 2) = 3   (paths 1-2-4 and 1-3-6 both end at leaves)
# chain 1→3→6 (right only) → 3, NOT 1
Complexity

Time O(n) · Space O(h)

Pattern Recognition
Pattern Insight

When "missing" and "smallest possible" are different concepts, handle missing children with explicit cases instead of letting the neutral element answer for them. This trap appears in any min-path-on-structure problem.


Binary Search Trees — when order does the searching

A binary search tree (BST) is a binary tree with a rule. Not just "children smaller/bigger" — the rule is about entire subtrees:

The BST property

For every node: all values in its left subtree are smaller than it, and all values in its right subtree are larger. "All subtree" — not just the two immediate children.

Our BST example — a second tree for the second half
            (8)
           /    \
       (3)        (10)
      /   \          \
   (1)     (6)        (14)
          /   \       /
       (4)     (7)  (13)
8
3
10
1
6
14
4
7
13
Check any node: everything under 3 is {1, 4, 6, 7} — all smaller than 8; everything under 10 is {13, 14} — all larger. Around 6: 4 on the left, 7 on the right. Every subtree obeys the same rule.

Search: discard half at every step

To find a value, compare with the current node and go one direction only. Every comparison throws away an entire subtree — the same discard-half move as binary search on a sorted array:

Python
def bst_search(root, target):
    node = root
    while node is not None:
        if target == node.val:
            return node             # found it
        node = node.left if target < node.val else node.right
    return None                     # fell off the tree: not present
Dry Run — bst_search(root, 6)
at 8:  6 < 8 → go left            (entire right subtree {10,13,14} discarded)
at 3:  6 > 3 → go right           (left subtree {1} discarded)
at 6:  6 == 6 → found ✅          3 comparisons instead of scanning 9 nodes
Dry Run — bst_search(root, 9)
at 8:  9 > 8 → go right
at 10: 9 < 10 → go left
10 has no left child → node is None → return None ✅ not present,
and proven absent in O(height) steps — no scan needed

Insertion is just an unsuccessful search: walk down as if searching for 5, and attach a new node at the None slot where the walk falls off:

Dry Run — inserting 5
at 8:  5 < 8 → left
at 3:  5 > 3 → right
at 6:  5 < 6 → left
at 4:  5 > 4 → right … but 4.right is None → ATTACH here

result: 4 gains a right child 5 — the BST property still holds everywhere ✅
Python — insert, min, max
def bst_insert(root, val):
    if root is None:
        return TreeNode(val)          # found the empty slot → attach here
    if val < root.val:
        root.left = bst_insert(root.left, val)
    else:
        root.right = bst_insert(root.right, val)
    return root                       # hand the (possibly updated) subtree up

def bst_min(root):                    # smallest = keep going left
    node = root
    while node.left:
        node = node.left
    return node.val                   # → 1 in our example

def bst_max(root):                    # largest = keep going right
    node = root
    while node.right:
        node = node.right
    return node.val                   # → 14 in our example

The inorder property — a BST hides a sorted list

Highlight this

Inorder traversal of a BST outputs the values in sorted order. Inorder visits "entire left, then me, then entire right" — and the BST property says left holds everything smaller and right everything larger. Property + traversal click together, and every BST, at any depth, sorts itself. Run our inorder on the BST above: 1 3 4 6 7 8 10 13 14 — sorted, for free.

This is the single most reused BST fact in interviews: need the k-th smallest value, the closest value to x, or to check a tree is a BST? Inorder is the door.

Validate BST — the classic trap

Given a tree, check whether it satisfies the BST property. The tempting check is wrong:

Warning — comparing only with the immediate parent

Check each node against just its children and you'll accept this tree:

Locally fine, globally broken
    5
     \
      8
     /
    3      ← 3 < 8 ✓ (parent check passes)  BUT 3 sits in the RIGHT
              subtree of 5, so it must be > 5. It isn't. Not a BST ❌

The BST property constrains a node against every ancestor, not just its parent. A value deep in the tree must respect the range carved out by all ancestors above it.

The fix: pass the legal range down. The root accepts anything; going left tightens the upper bound to the current node; going right tightens the lower bound:

Python
def is_valid_bst(root):
    def check(node, low, high):          # node must satisfy low < val < high
        if node is None:
            return True
        if not (low < node.val < high):
            return False                 # violates some ancestor's rule
        return (check(node.left, low, node.val)      # left: must stay < node
                and check(node.right, node.val, high))  # right: must stay > node
    return check(root, float('-inf'), float('inf'))   # root: no limits yet

# on the 5/8/3 tree above: check(3, 5, 8) → 3 > 5 is False → correctly rejected ✅
Q3. In the 5 / 8 / 3 tree above, why does the parent-only check fail to catch the violation?
3 is a legal left child of 8, but it lives in 5's right subtree, so it must exceed 5. Only ancestor-range checks see that constraint.

How fast are BST operations?

OperationBalanced treeWorst case (a straight chain)
searchO(log n) — half discarded per stepO(n) — the "tree" degenerates into a list
insertO(log n)O(n)
min / maxO(log n) — one leftmost / rightmost walkO(n)
inorder (sorted output)O(n) — every node once, any shapeO(n)

All BST ops cost O(h), the height. h = log n when the tree is bushy; h = n when insertions arrive sorted and the tree grows into a chain. Keeping h near log n is the job of self-balancing trees — a topic deliberately left for later; the concept you have here is the foundation.

What You Learned

  • A tree is hierarchical and cycle-free: one root, parent→child edges, leaves at the bottom; a binary tree caps children at two, stored as left/right pointers (None = missing).
  • Depth counts edges from the root; height is the longest root-to-leaf path (we count nodes: empty = 0, single node = 1).
  • The three DFS traversals differ in when the node itself is visited: pre (first), in (middle), post (last) — all O(n).
  • DFS can be written with an explicit stack; level-order needs a queue and the "freeze the level size" trick.
  • Nearly every tree problem is: base case for None + combine the two subtree answers (count/sum: +, max: max, search: or).
  • A BST orders entire subtrees (all-left smaller, all-right larger), giving O(h) search/insert and a free sorted listing via inorder. Validating it needs ancestor ranges, not parent comparisons.

Pattern Recognition

Hierarchical data ("reports to", "contains", "category → subcategory") ⇒ model as a tree. "Visit/transform/count everything" ⇒ a traversal — pick DFS unless the problem talks about levels or rows. "Longest/shortest/does-path-exist between ancestors" ⇒ recursion on subtrees. And the moment a problem says values are ordered or asks for k-th smallest / closest value, think BST + inorder.

Complexity Summary

TaskTimeExtra space
Any DFS traversal (pre/in/post)O(n)O(h) — recursion or explicit stack
Level-order traversalO(n)O(w) — widest level in the queue
Count / sum / max / search (plain tree)O(n)O(h)
Identical trees / invert / min depthO(n)O(h)
BST search / insert / min / maxO(h) = O(log n) balanced, O(n) chainO(1) iterative
Validate BST (range pass-down)O(n)O(h)

n = nodes, h = height, w = maximum width of a level. Every "O(h)" degrades to O(n) on a chain-shaped tree.

Common Mistakes

  • Forgetting the None base case — the single most common tree bug; node.left.left on a missing child crashes.
  • Height off-by-one — mixing the nodes convention (single node = 1) with the edges convention (= 0) mid-solution.
  • Treating a missing child as depth 0 in min-depth — 16.9's trap; a None is not a leaf.
  • Overwriting pointers before saving them in invert — capture left_done/right_done first.
  • Validating a BST against only the parent — the 5/8/3 trap; pass (low, high) ranges down instead.
  • Using a stack for level-order — LIFO reverses levels; a queue's FIFO order is what breadth-first needs.

Mini Quiz

Q1. Preorder, inorder and postorder of a tree — what do all three have in common?
All three DFS orders keep left-before-right; they differ only in when the node itself is recorded. (Sorted output is the BST-inorder special case.)
Q2. A BST contains 1,023 nodes and is perfectly balanced. How many comparisons to find a value, worst case?
A perfect tree with 10 levels holds 2¹⁰ − 1 = 1023 nodes; each level costs one comparison and discards half the tree → 10 comparisons. (2¹⁰ = 1024.)
Q3. Which traversal of a BST prints values in ascending order?
Inorder = all smaller (left), then me, then all larger (right) — recursively that sorts the whole tree.
Q4. The height (counting nodes) of a single-node tree is…
With our convention: empty tree 0, one node 1, each level adds 1. (Edges convention: single node = 0 — always check which is expected.)
Q5. Why does BST validation pass (low, high) bounds down instead of comparing with the parent?
Each left turn tightens the upper bound, each right turn the lower bound — the node must satisfy all of them at once.

Practice Problems

P16.1 Count Leaf Nodes

🟢 L1 · Understand
Problem

Count the leaves (nodes with no children) of a binary tree. Our example tree → 3 (nodes 4, 5, 6).

Key Observation

A leaf detects itself: left is None and right is None. Non-leaves add their two subtrees' leaf counts.

Show Hint

Three cases: empty subtree → 0; leaf → 1; otherwise → recurse into both children.

Show Approach

Base None → 0. If both children are None return 1. Otherwise return count_leaves(left) + count_leaves(right).

Show Solution
Python
def count_leaves(node):
    if node is None:
        return 0
    if node.left is None and node.right is None:
        return 1                       # this node IS a leaf
    return count_leaves(node.left) + count_leaves(node.right)

# our example tree → 3
Complexity

Time O(n) · Space O(h)

Pattern Recognition
Pattern Insight

A node can classify itself (leaf? matches value? within range?) and only then recurse — "self-check then delegate" is the universal tree-recursion reading order.

P16.2 Depth of a Given Value

🟡 L2 · Apply
Problem

Return the depth (root = 0) of the node holding target, or -1 if absent. In our example tree, depth of 6 is 2.

Key Observation

Carry the current depth as an argument. If this node matches, return it; otherwise the answer is whichever subtree finds the target.

Show Hint

Search the left subtree first; if it reports -1, try the right.

Show Approach

depth_of(node, target, d): None → -1; match → d; else left result, and if that's -1, the right result.

Show Solution
Python
def depth_of(node, target, d=0):
    if node is None:
        return -1                      # not down this path
    if node.val == target:
        return d
    left = depth_of(node.left, target, d + 1)
    if left != -1:
        return left
    return depth_of(node.right, target, d + 1)

# depth_of(our_tree, 6) → 2 · depth_of(our_tree, 1) → 0
Complexity

Time O(n) · Space O(h)

Pattern Recognition
Pattern Insight

Carrying a running value downward (depth, path-so-far, running sum) turns "compute bottom-up" recursion into "pass state top-down" recursion — the second fundamental tree pattern.

P16.3 Sorted Array → Balanced BST

🟡 L2 · Apply
Problem

Given a sorted array of distinct values, build a height-balanced BST from it. [1, 2, 3, 4, 5, 6, 7] → a tree with 4 at the root.

Key Observation

The middle element is the root: everything smaller goes left, everything larger goes right — exactly the BST property. Each half is the same problem on a smaller array.

Show Hint

Pick the middle, recurse on the left half and the right half, attach the results.

Show Approach

build(nums): if empty → None. mid = len//2; node = TreeNode(nums[mid]); node.left = build(nums[:mid]); node.right = build(nums[mid+1:]).

Show Solution
Python
def sorted_to_bst(nums):
    if not nums:
        return None
    mid = len(nums) // 2
    node = TreeNode(nums[mid])
    node.left = sorted_to_bst(nums[:mid])
    node.right = sorted_to_bst(nums[mid + 1:])
    return node

# sorted_to_bst([1,2,3,4,5,6,7]).val → 4
# inorder of the built tree → [1, 2, 3, 4, 5, 6, 7] — the input again ✅
Complexity

Time O(n log n) as written (slicing copies); O(n) with index bounds · Space O(log n) depth

Pattern Recognition
Pattern Insight

Divide-and-conquer construction: split at the middle, solve both halves. Notice the round trip — sorted list → BST → inorder → sorted list. The two representations store the same order.

Challenge Problem

P16-C Right Side View

🟠 L3 · Recognize
Problem

Return the values visible when looking at the tree from the right — the last node of every level. Our example tree → [1, 3, 6].

Key Observation

"Last node of every level" is a per-level question — the freeze-the-level-size technique from 16.3 answers it directly: in each round, the node popped last in that round is the rightmost.

Show Hint

Copy the level-order-levels loop, but instead of collecting every node, remember only the final pop of each round.

Show Approach

for i in range(level_size): pop, enqueue children; if i == level_size - 1, append node's value to the result.

Show Solution
Python
from collections import deque

def right_side_view(root):
    if root is None:
        return []
    result = []
    queue = deque([root])
    while queue:
        level_size = len(queue)
        for i in range(level_size):
            node = queue.popleft()
            if i == level_size - 1:     # last of this level
                result.append(node.val)
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
    return result

# our example tree → [1, 3, 6] ✅
Complexity

Time O(n) · Space O(w)

Pattern Recognition
Pattern Insight

Any "first/last/average/zigzag per level" question is level-order + a tiny per-round tweak. Recognizing the BFS skeleton under the fancy wording is the whole game.

Revision Checklist

PART 17

Heap / Priority Queue

When you don’t need everything sorted — you just need the best next item, over and over.

Sorting answers every “what’s the smallest?” question in one shot. But if data keeps arriving, or you only ever need the current best item, re-sorting the whole collection after every change is wasteful. A heap is the data structure built for exactly this job: it hands you the minimum in O(1), and after each insert or removal it repairs itself in O(log n). In Python the heap lives in a module called heapq, and it powers a surprising number of interview problems — especially anything with the words “k largest”, “kth”, or “most frequent” in it.

Why “just sort it” isn’t always the answer

Three everyday situations where sorting is the wrong tool:

  • A live stream. A million exam scores arrive one at a time, and you must report the top 10 after each one. Re-sorting everything per score is O(n log n) per update; a heap of size 10 answers instantly.
  • A scheduler. An operating system or a print queue must always run the most urgent task next — it never needs the full sorted order, just the current winner.
  • Repeated minimums. You insert, ask “smallest?”, remove, insert again… hundreds of times. Sorting once doesn’t help because the data keeps changing.
Data keeps changing
→
Repeated “give me the best”
→
Priority queue (heap)

What is a heap?

Concept

A heap is a complete binary tree where every parent is ≤ its children. That single rule guarantees the smallest element sits at the root. Python stores this tree inside a plain list, so you use it exactly like a list — the tree shape is just how heapq thinks internally. You never need to touch the tree yourself.

For example, the list [1, 3, 2, 6, 4, 5] is a valid heap. Drawn as a tree:

1
3
2
6
4
5
The list [1, 3, 2, 6, 4, 5] as a tree — every parent ≤ its children, so the minimum (1) is the root

Notice what the rule does not say: it does not say the tree is fully sorted. Look at the last level — 6 comes before 4. A heap only promises that the root is the minimum and each parent is ≤ its children. That weaker promise is exactly why repairs are cheap.

Important — heapq is a MIN-heap

Python’s heapq always keeps the smallest element on top. heap[0] is the minimum, and heappop removes the minimum. There is no max=True switch. To simulate a max-heap you negate the values (trick shown below). Forgetting this is the single most common heap bug.

Quick check — in Python’s heapq, what does heappop return?
heapq implements a min-heap: the smallest element always sits at index 0 and leaves first — no matter when it was pushed.

heapq in one minute

The API is tiny. You push, you peek, you pop:

Python
import heapq

heap = []                      # just a plain Python list
heapq.heappush(heap, 5)
heapq.heappush(heap, 1)
heapq.heappush(heap, 3)

print(heap[0])                 # 1  → peek: smallest, without removing (O(1))
print(heapq.heappop(heap))     # 1  → smallest comes out first
print(heapq.heappop(heap))     # 3
print(heapq.heappop(heap))     # 5
Operationheapq codeTimeWhat you get
Insertheapq.heappush(h, x)O(log n)nothing (heap updated in place)
Remove smallestheapq.heappop(h)O(log n)the smallest value
Look at smallesth[0]O(1)the smallest value, heap unchanged
Build from a listheapq.heapify(lst)O(n)rearranges lst into a valid heap, in place
Sizelen(h)O(1)number of items

Why is push/pop O(log n) and not O(n)? Because a repair only travels along one path of the tree — and a complete binary tree holding n items is only about log n levels deep. Compare the costs:

peek O(1)
push O(log n)
rescan O(n)
re-sort O(n log n)
Cost of one “best item” query on changing data — the heap beats both scanning and re-sorting
Why does a repair only touch one path? (gentle answer)

When you push a value, heapq places it at the end and lets it bubble up: as long as it is smaller than its parent, the two swap. Each swap moves the value one level up, and there are only ~log n levels. Popping is the mirror image: the last element replaces the root and sinks down by swapping with its smaller child. One element, one path, O(log n) — the rest of the tree was already valid and stays valid.

heapify — turning a list into a heap

If you already have a list and only now need heap behaviour, don’t push items one by one. heapq.heapify rearranges the list in place into a valid heap in a single O(n) pass:

Python
import heapq

nums = [7, 2, 9, 4]
heapq.heapify(nums)             # nums is now a valid min-heap (in place)
print(nums[0])                  # 2 → smallest available in O(1)
print(heapq.heappop(nums))      # 2
print(heapq.heappop(nums))      # 4
# further pops would yield 7, then 9 — elements leave in sorted order

A neat consequence: heapify once, then heappop n times, and the elements come out fully sorted. (That idea is so useful it even has a name — heapsort — but for everyday problems you’ll mostly use heapify as a fast setup step.)

The max-heap trick: negate everything

heapq has no max-heap — but numbers have a neat symmetry: the largest value has the smallest negation. So store -x instead of x, and the min-heap of negated values behaves exactly like a max-heap of the originals. Negate on the way in, negate back on the way out:

Python
import heapq

def biggest_first(nums):
    heap = []
    for x in nums:
        heapq.heappush(heap, -x)          # store the negation

    out = []
    while heap:
        out.append(-heapq.heappop(heap))  # negate back on the way out
    return out

print(biggest_first([3, 1, 4, 1, 5]))     # [5, 4, 3, 1, 1] → largest first!
Tip

Say it as a mantra: “negate in, negate out.” Every -x you push must be negated again when you read it. Forgetting the second negation and returning -9 instead of 9 is a classic (and embarrassing) submission bug.

Use heapq directly — it already is a min-heap.

Python
import heapq

tasks = [5, 1, 3]
heapq.heapify(tasks)
shortest = tasks[0]              # 1 — peek, O(1)

Negate values in, negate results out — a min-heap of -x is a max-heap of x.

Python
import heapq

scores = [5, 1, 3]
heap = [-s for s in scores]
heapq.heapify(heap)
top = -heap[0]                   # 5 — largest, O(1)

Worked Problems

17.1 Kth Largest Element in an Array

🟡 L2 · Apply
Problem

Given an array nums and an integer k, return the kth largest element. Note it is the kth largest in sorted order, not the kth distinct element.

Input / Output

nums = [3, 2, 1, 5, 6, 4], k = 2 → 5  (sorted: [1, 2, 3, 4, 5, 6], the 2nd largest is 5)

Key Observation

We don’t need the array sorted — we only need the top k slice. So keep a heap holding at most k candidates: the k biggest values seen so far. A min-heap is perfect for this, which sounds backwards until you see why: the root of that heap is the smallest of the current top k — the “weakest champion”. A new element deserves a spot only if it beats the weakest champion, and evicting the weakest is exactly what a min-heap does in O(log k).

Brute Force

Sort and index: sorted(nums)[-k]. Correct, O(n log n), and honestly fine in interviews as a first answer. The heap version matters when k is small compared to n — or when the data is a stream you can’t sort because it never ends.

Optimal Idea

Scan once. Push every element into a min-heap; the moment the heap exceeds size k, pop. Everything small is evicted as soon as it arrives, so at the end the heap holds exactly the k biggest values, and heap[0] — the smallest of those — is the kth largest. Work that repeated work disappeared: no re-sorting, and small elements are discarded in O(log k) instead of being carried along.

Python Solution
Python
import heapq

def find_kth_largest(nums, k):
    heap = []                       # min-heap holding the k biggest seen so far
    for x in nums:
        heapq.heappush(heap, x)
        if len(heap) > k:
            heapq.heappop(heap)     # evict the weakest of the k+1 candidates
    return heap[0]                  # smallest of the k biggest = kth largest
Dry Run
Dry Run — nums = [3, 2, 1, 5, 6, 4], k = 2
push 3 → heap contents [3]
push 2 → [2, 3]
push 1 → [1, 2, 3] → size 3 > 2 → pop 1 → [2, 3]
push 5 → [2, 3, 5] → size 3 > 2 → pop 2 → [3, 5]
push 6 → [3, 5, 6] → size 3 > 2 → pop 3 → [5, 6]
push 4 → [4, 5, 6] → size 3 > 2 → pop 4 → [5, 6]

heap[0] = 5 ✅   (heap holds the 2 biggest: 5 and 6; the weaker one is the answer)
Complexity

Time O(n log k) — each of the n elements costs one push plus at most one pop, each O(log k). Space O(k). When k = 2 and n = 1,000,000, that’s roughly a 20-step repair per element instead of a million-element sort.

Edge Cases
  • k = 1 → the maximum (heap of size 1, root is the running max).
  • k = len(nums) → the minimum (heap holds everything).
  • Duplicates are fine — the heap stores values, not distinct keys.
Common Mistakes
  • Building the size-k structure as a max-heap — for “k largest” it must be a min-heap: its root is the weakest champion, the boundary every newcomer is compared against. A max-heap of size k only ever surfaces the overall largest, not the kth.
  • Forgetting the len(heap) > k check → the heap grows to n and you’ve just re-implemented sorting, slowly.
  • Peeking heap[0] on an empty heap (e.g. k = 0 or empty input) → IndexError.
Pattern Recognition
Pattern Insight

“kth largest / kth smallest / top k” in the statement → a heap of size k. For the k largest, keep a min-heap (its root is the weakest champion). For the k smallest, keep a max-heap via negation (its root is the strongest of the k survivors). You never fully sort — you only ever compare against the current boundary.

17.2 Top K Frequent Elements

🟠 L3 · Recognize
Problem

Given nums and k, return the k values that appear most frequently. Any order is accepted.

Input / Output

nums = [1, 1, 1, 2, 2, 3], k = 2 → [1, 2]  (1 appears 3×, 2 appears 2×, 3 appears 1×)

Key Observation

Two sub-problems in disguise. First, count each value — that’s a dictionary pass (Part 2 pattern). Second, pick the k entries with the biggest counts — and that is 17.1 again, just over counts instead of values. Whenever you can say “this is top-k over something”, the size-k min-heap template applies unchanged.

Brute Force

Count into a dict, then sorted(counts.items(), key=lambda p: -p[1])[:k]. O(n + m log m) where m is the number of distinct values. Fine — but the heap keeps only k candidates alive, O(n log k), which wins when the vocabulary is huge (say, millions of distinct strings) and k is small.

Optimal Idea

Heap entries are tuples (frequency, value) — frequency first, so the heap compares by frequency and ranks by frequency, exactly what “most frequent” needs. Keep the heap at size k by evicting the least frequent candidate. At the end, drain the heap and reverse so the output reads most-frequent-first.

Python Solution
Python
import heapq

def top_k_frequent(nums, k):
    counts = {}
    for x in nums:
        counts[x] = counts.get(x, 0) + 1

    heap = []                        # min-heap of (frequency, value)
    for value, freq in counts.items():
        heapq.heappush(heap, (freq, value))
        if len(heap) > k:
            heapq.heappop(heap)      # evict the least frequent candidate

    result = []
    while heap:                      # drain: least frequent comes out first
        freq, value = heapq.heappop(heap)
        result.append(value)
    result.reverse()                 # most frequent first
    return result
Dry Run
Dry Run — nums = [1, 1, 1, 2, 2, 3], k = 2
counting → counts = {1: 3, 2: 2, 3: 1}   (insertion order)

push (3, 1) → heap [(3, 1)]
push (2, 2) → heap [(2, 2), (3, 1)]
push (1, 3) → heap [(1, 3), (3, 1), (2, 2)] → size 3 > 2 → pop (1, 3) → [(2, 2), (3, 1)]

drain:  pop (2, 2) → result [2]
        pop (3, 1) → result [2, 1]
reverse → [1, 2] ✅   (1 is most frequent, then 2)
Complexity

Time O(n log k): an O(n) counting pass, then O(m log k) over the m ≤ n distinct values. Space O(m) for the counts plus O(k) for the heap.

Edge Cases
  • k equals the number of distinct values → everything is returned.
  • Ties in frequency → any valid set of k is acceptable (the tuple’s second field just breaks ties deterministically).
  • Empty nums → return [] (guard the drain loop — it naturally returns an empty list).
Common Mistakes
  • Pushing (value, freq) instead of (freq, value) → the heap ranks by value, silently wrong.
  • Forgetting result.reverse() → output is least-frequent-first. (Often still “accepted” if order doesn’t matter — but be deliberate.)
  • Counting with counts.get(x, 0) + 1 vs counts[x] = counts[x] + 1 — the latter crashes on unseen keys; the former doesn’t.
Pattern Recognition
Pattern Insight

Counting + top-k = dictionary then size-k heap. The clue is the word frequent: first reduce the data to (item, importance) pairs, then run the exact 17.1 template over the importance field. This compose-two-patterns move — dict pass, then heap pass — covers “top k words”, “k closest points”, “k most common IPs”… all with the same skeleton.

Quick Drills

Drill — K Largest Elements (return all k, biggest first)

🟢 L1

nums = [7, 2, 9, 4, 1], k = 3 → [9, 7, 4]. Identical to 17.1 — except you return the whole heap, sorted.

Show Solution
Python
import heapq

def k_largest(nums, k):
    heap = []
    for x in nums:
        heapq.heappush(heap, x)
        if len(heap) > k:
            heapq.heappop(heap)
    return sorted(heap, reverse=True)   # biggest first

Shortcut worth knowing: heapq.nlargest(k, nums) does this in one line — but in an interview, write the loop version first; it proves you understand the mechanism.

Drill — K Smallest Elements (using the max-heap trick)

🟢 L1

nums = [5, 3, 8, 1, 9], k = 2 → [1, 3]. Flip the logic of 17.1: to keep the k smallest, the heap must evict its largest member — so simulate a max-heap with negation.

Show Solution
Python
import heapq

def k_smallest(nums, k):
    heap = []                        # negated values → behaves like a max-heap
    for x in nums:
        heapq.heappush(heap, -x)
        if len(heap) > k:
            heapq.heappop(heap)      # evicts the LARGEST original value
    return sorted(-x for x in heap)  # negate back, smallest first

Watch one eviction on [5, 3, 8, 1, 9], k = 2 (heap stores negated values): push -5, -3 → heap [-5, -3]. Push -8 → three items → the smallest negation -8 (largest original, 8) is evicted. Push -1 → evicts -5 (original 5). Push -9 → evicts -9 itself (original 9). Survivors: {-3, -1} → originals {3, 1} → answer [1, 3] ✅.

17.3 Merge k Sorted Lists (beginner version)

🟡 L2 · Apply
Problem

You are given k lists, each already sorted in ascending order. Merge them into one sorted list.

Input / Output

lists = [[1, 4, 5], [1, 3], [2, 6]] → [1, 1, 2, 3, 4, 5, 6]

Key Idea

At any moment, the next output element must be the smallest among the front-runners — the first not-yet-taken element of each list. That’s a “repeatedly ask for the smallest” job, i.e. a heap of at most k items. Each heap entry is the tuple (value, list_index, element_index):

  • value first → the heap ranks by value (that’s what we want).
  • list_index, element_index → pure bookkeeping, so that after popping we know which list to refill from. As a bonus they break ties deterministically, and since they are plain integers, heapq never runs into incomparable objects.

Loop: pop the smallest front-runner, append it to the output, and push that same list’s next element. Total elements N, lists k → every element does one O(log k) pop and one push: O(N log k), space O(k). Compare with concatenating and sorting everything: O(N log N) — the heap wins because it only ever compares k front-runners, never the whole data.

Python Solution
Python
import heapq

def merge_k_sorted(lists):
    heap = []
    for i, lst in enumerate(lists):
        if lst:                          # skip empty lists
            heapq.heappush(heap, (lst[0], i, 0))

    result = []
    while heap:
        value, list_idx, elem_idx = heapq.heappop(heap)
        result.append(value)
        next_idx = elem_idx + 1
        if next_idx < len(lists[list_idx]):
            heapq.heappush(heap, (lists[list_idx][next_idx], list_idx, next_idx))
    return result
Dry Run
Dry Run — lists = [[1, 4], [2, 3]]
seed:   push (1, 0, 0), push (2, 1, 0) → heap [(1, 0, 0), (2, 1, 0)]

pop (1, 0, 0) → result [1]      · list 0 has a next element → push (4, 0, 1)
                                 heap [(2, 1, 0), (4, 0, 1)]
pop (2, 1, 0) → result [1, 2]   · push (3, 1, 1) → heap [(3, 1, 1), (4, 0, 1)]
pop (3, 1, 1) → result [1, 2, 3] · list 1 exhausted → nothing pushed
pop (4, 0, 1) → result [1, 2, 3, 4] · list 0 exhausted

heap empty → return [1, 2, 3, 4] ✅
Edge Cases
  • Empty lists inside lists → the if lst: guard skips them.
  • All lists empty → heap never gets seeded, returns [].
Pattern Recognition
Pattern Insight

k sorted anything → heap of front-runners. The clue is “multiple sorted sources, one merged output”. The tuple trick (value, source_id, position) generalizes far beyond lists — the same shape merges calendar feeds, log files, or search-result streams.

The Top-k Radar

Heap problems rarely announce themselves with the word “heap”. They announce themselves with a quantity: the kth something, the k somethings, the most-frequent somethings. When you spot one, run this radar:

Spot “kth / top k / most frequent”
→
Reduce data to (item, importance)
→
Heap of size k
→
heap[0] is the boundary
Pattern Insight

“top k / kth / most frequent → heap of size k.” Full sorting costs O(n log n) and keeps every element in memory; the size-k heap costs O(n log k) and holds only k slots — which is why it also works on endless streams. Direction rule: hunting the k largest → min-heap (evict the weakest); hunting the k smallest → negated max-heap (evict the strongest). And if the problem wants the full sorted order, don’t use a heap at all — just sort.

What You Learned

  • A heap is a complete binary tree where every parent ≤ its children — so the minimum always sits at the root. Python stores it in a plain list via heapq.
  • heapq is a min-heap: heappush O(log n), heappop O(log n), peek heap[0] O(1), heapify O(n).
  • There is no max-heap flag — you simulate one by negating values in and out.
  • “Kth largest / top k / most frequent” → keep a heap of size k; its root is the boundary element every newcomer must beat.
  • Heap entries can be tuples: the first field decides the ranking, the rest is bookkeeping (and breaks ties).
  • Merging k sorted sources = a heap of front-runners, O(N log k).

Pattern Recognition

Reach for heapq when a problem repeatedly asks for the current best item, or asks for only a top-k slice, or merges several sorted streams. If the input is static and the whole order is needed, sort. If the data changes between queries, the heap’s O(log n) repair beats every re-scan. The strongest tell of all: the answer is defined by a count (kth, top k, most frequent), not by a position or a range.

Complexity Summary

Operation / patternTimeExtra space
Push / popO(log n)O(1)
Peek — heap[0]O(1)O(1)
heapify (build from list)O(n)O(1) — in place
Top-k over n items (heap of size k)O(n log k)O(k)
Merge k sorted lists (N total items)O(N log k)O(k)
Drain a heap of n items (heapsort flavour)O(n log n)O(1)

Common Mistakes

  • Assuming heapq is a max-heap. It is a min-heap — the smallest element leaves first.
  • Negating values on the way in but forgetting to negate on the way out (returning -9 instead of 9).
  • Putting uncomparable data in tie-breaking positions — (freq, some_list) crashes with TypeError when frequencies tie; use an index instead.
  • Peeking or popping an empty heap → IndexError. Check if heap: first.
  • Missing the len(heap) > k eviction check, silently turning your top-k into a full sort.
  • Using a heap when the problem wants the complete sorted order — a plain sorted() is simpler and faster.

Mini Quiz

Q1. Ten million scores stream in one at a time and you must report the 3rd largest after each one. What do you keep?
The min-heap’s root is the weakest of the current top 3 — the boundary every new score is compared against. Each update costs O(log 3).
Q2. What does looking at heap[0] cost?
Indexing a list is O(1), and the heap property guarantees the minimum is always at index 0. Only push/pop pay the O(log n) repair.
Q3. How do you simulate a max-heap with heapq?
Negation flips the order: the largest original has the smallest negation, so it surfaces at the root. Remember: negate in, negate out.
Q4. heapq.heapify(lst) rearranges a list into a heap. What does it cost?
heapify repairs bottom-up in one linear pass — cheaper than n pushes at O(log n) each. (Building by n pushes is O(n log n); still fine, just not optimal.)

Practice Problems

P17-A · Kth Smallest Element in an Array

🟢 L1 · Understand

nums = [7, 2, 9, 4, 1], k = 2 → 2. The mirror image of 17.1.

Show Hint

To keep the k smallest survivors, the heap must evict its largest member when it overflows. Which heap flavour does that?

Show Approach

A max-heap of size k, built with the negation trick. Its root is the largest of the k smallest-so-far — the boundary a newcomer must be smaller than. At the end the root is the kth smallest.

Show Solution
Python
import heapq

def kth_smallest(nums, k):
    heap = []
    for x in nums:
        heapq.heappush(heap, -x)        # negated → max-heap behaviour
        if len(heap) > k:
            heapq.heappop(heap)         # evict the largest of the k+1 candidates
    return -heap[0]                     # negate back

Time O(n log k), space O(k).

P17-B · Last Stone Weight

🟡 L2 · Apply

Each turn, take the two heaviest stones and smash them together. If they are equal, both are destroyed; otherwise the survivor weighs heaviest - second and goes back into the pile. Return the weight of the last stone (or 0 if none remain). [2, 7, 4, 1, 8, 1] → 1.

Show Hint

Every turn needs “the two largest, right now” — and smashing changes the data. Sounds like which structure?

Show Approach

Max-heap via negation. Loop: pop twice to get the two heaviest; if they differ, push back the negated difference. Stop when fewer than two stones remain.

Show Solution
Python
import heapq

def last_stone_weight(stones):
    heap = [-s for s in stones]
    heapq.heapify(heap)
    while len(heap) > 1:
        first = -heapq.heappop(heap)    # heaviest
        second = -heapq.heappop(heap)   # second heaviest
        if first != second:
            heapq.heappush(heap, -(first - second))
    return -heap[0] if heap else 0

On [2, 7, 4, 1, 8, 1]: smash 8+7 → push 1; smash 4+2 → push 2; smash 2+1 → push 1; smash 1+1 → both gone; one stone of weight 1 remains ✅. Time O(n log n).

P17-C · Sort a Nearly Sorted (k-Sorted) Array

🟡 L2 · Apply

Every element is at most k positions away from where it belongs in the fully sorted array. Sort the array faster than O(n log n). nums = [6, 5, 3, 2, 8, 10, 9], k = 3 → [2, 3, 5, 6, 8, 9, 10].

Show Hint

If nothing is ever more than k spots from home, how far into the input do you need to look before the true next-smallest element must have appeared?

Show Approach

Slide a window of size k + 1: push elements into a min-heap, and whenever the window overflows, pop — that popped element is guaranteed to be the next one in sorted order. The guarantee: among any k + 1 consecutive input elements, one of them must belong at the front of the output.

Show Solution
Python
import heapq

def sort_nearly_sorted(nums, k):
    heap = []
    result = []
    for x in nums:
        heapq.heappush(heap, x)
        if len(heap) > k + 1:
            result.append(heapq.heappop(heap))
    while heap:
        result.append(heapq.heappop(heap))
    return result

Time O(n log k) — a real win when k is small. Space O(k).

Challenge Problem

Challenge · Kth Largest Element in a Stream

🟠 L3 · Recognize

Design a class: the constructor takes k and an initial list nums; each call to add(val) appends a new score and must return the kth largest element seen so far. It’s 17.1 — except the data keeps arriving, which is exactly the case where sorting once is impossible and the size-k heap shines.

Show Hint

The kth largest of a growing dataset changes by at most one element per addition. What boundary element would you need to keep around?

Show Approach

Keep a min-heap of at most k elements, fed through the same add logic: push, then evict while larger than k. The root is always the current kth largest. Constructor can simply call add for each initial element.

Show Solution
Python
import heapq

class KthLargest:
    def __init__(self, k, nums):
        self.k = k
        self.heap = []
        for x in nums:
            self.add(x)

    def add(self, val):
        heapq.heappush(self.heap, val)
        if len(self.heap) > self.k:
            heapq.heappop(self.heap)    # keep only the k biggest
        return self.heap[0]             # kth largest so far

Each add is O(log k). Edge note: the classic statement guarantees the kth largest exists after every add (enough total elements) — before the heap reaches size k, heap[0] is merely the smallest value seen so far.

Revision Checklist

PART 18

Graph Basics

Nodes, edges, and the two traversals that unlock half of all graph problems.

A friend suggestion (“people you may know”), a route on a map, course prerequisites, a maze in a game — all of these are the same object: things connected to other things. That object is a graph, and its superpower is that once you learn two traversal routines — BFS and DFS — you can answer an enormous family of questions with them: is it reachable? how many pieces are there? what’s the fewest steps? This chapter builds exactly the beginner core: vocabulary, the Python way to store a graph, both traversals, and the trick of spotting graphs hiding inside grids.

Nodes and edges — the vocabulary

Concept

A graph is a set of nodes (also called vertices) plus a set of edges connecting pairs of nodes. Nothing more. Everything else — direction, weights, traversal — is a variation on those two ingredients.

  • Social network → nodes are people, edges are friendships.
  • City map → nodes are intersections, edges are road segments.
  • Course plan → nodes are courses, edges are prerequisites.
  • Chess board puzzle → nodes are board states, edges are legal moves.
Undirected — edges go both ways

A friendship: if A knows B, B knows A. Store both directions in your adjacency list. Most beginner problems are undirected.

Directed — edges have a direction

“A follows B” or “course A requires B”: the reverse is not implied. Store exactly the directions given — an arrow, not a road.

Edges can also be unweighted (every step counts as 1) or weighted (each edge has a cost, like kilometres or minutes). This book works with unweighted graphs — happily, that’s where the beginner patterns live. 1 → 2 → 3 costs 2 steps, and “fewest steps” questions have a beautiful one-algorithm answer that you’ll meet in a few paragraphs.

Storing a graph: adjacency list vs adjacency matrix

In Python the natural home for a graph is a dictionary of lists — the adjacency list. Key = node, value = its direct neighbours. You build it straight from an edge list in one loop:

Python
edges = [(1, 2), (1, 3), (2, 4), (3, 5)]

graph = {}
for a, b in edges:
    graph.setdefault(a, []).append(b)
    graph.setdefault(b, []).append(a)   # undirected → add BOTH directions

# graph is now:
# {
#     1: [2, 3],
#     2: [1, 4],
#     3: [1, 5],
#     4: [2],
#     5: [3],
# }
Common Mistake

Forgetting graph.setdefault(b, []).append(a) for an undirected graph. With one-way storage, a traversal entering node 4 simply cannot get back to node 2 — and your component count silently explodes.

The alternative is the adjacency matrix: an n × n grid of 0/1 where cell [a][b] says whether an edge exists:

Python
n = 6                              # nodes 0..5
matrix = [[0] * n for _ in range(n)]
for a, b in [(0, 1), (1, 2), (3, 4)]:
    matrix[a][b] = 1
    matrix[b][a] = 1               # undirected → fill both cells
QuestionAdjacency list (dict of lists)Adjacency matrix (n × n)
MemoryO(V + E) — one slot per node + per edgeO(V²) — always, even for few edges
“Is (u, v) an edge?”O(degree of u) — scan its listO(1) — direct lookup
Visit all neighbours of uO(degree)O(V) — even if u has 1 neighbour
Best forsparse graphs — nearly every real problemdense graphs, tiny V, heavy edge-checking
Tip

Default to the adjacency list. Road networks, social networks and grids are all sparse — most nodes touch only a handful of others — and traversal algorithms naturally want to “loop over neighbours”, which a list gives you directly.

BFS — explore level by level

Take this small graph — node 1 is the start:

Graph
        1
       / \
      2   3
      |   |
      4   5

edges: 1—2, 1—3, 2—4, 3—5

Breadth-First Search visits like ripples in a pond: the start, then everything 1 step away, then everything 2 steps away, and so on. It manages this with two tools:

  • a queue (first in, first out) — the oldest discovered node is always processed next, which is exactly what produces “level by level” order;
  • a visited set — nodes already seen are never enqueued again.
Level 0
1
→
Level 1
2 · 3
→
Level 2
4 · 5
BFS from node 1 discovers the graph in waves — and the wave number IS the distance from the start
Python
from collections import deque

def bfs_order(graph, start):
    visited = {start}            # mark BEFORE enqueueing → no duplicates
    queue = deque([start])
    order = []

    while queue:
        node = queue.popleft()   # oldest node first → level by level
        order.append(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

    return order

print(bfs_order(graph, 1))       # [1, 2, 3, 4, 5]
Dry Run — BFS from 1
start          queue [1]    visited {1}         order []

pop 1 → see 2, 3 → queue [2, 3]   visited {1, 2, 3}   order [1]
pop 2 → see 4     → queue [3, 4]   visited {1, 2, 3, 4}   order [1, 2]
pop 3 → see 5     → queue [4, 5]   visited {1, 2, 3, 4, 5}   order [1, 2, 3]
pop 4 → nothing new → queue [5]   order [1, 2, 3, 4]
pop 5 → nothing new → queue []    order [1, 2, 3, 4, 5] ✅
Common Mistake — skip the visited set and it never ends

Graphs have cycles: in the tiny graph above, 1 leads to 2, and 2’s neighbour list contains 1 again. Without a visited set, BFS re-enqueues 1 forever — an infinite loop. The rule: mark a node visited the moment you enqueue it, so it can never enter the queue twice.

DFS — go deep, then backtrack

Depth-First Search is the opposite temperament: from the start, walk as deep as possible along one path; when you hit a dead end, backtrack to the last junction and try the next branch. Recursion expresses this beautifully — the call stack is the backtrack mechanism:

Python
def dfs_order(graph, node, visited, order):
    visited.add(node)
    order.append(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs_order(graph, neighbor, visited, order)   # go deeper

order = []
dfs_order(graph, 1, set(), order)
print(order)                 # [1, 2, 4, 3, 5]
Dry Run — DFS from 1 (neighbours visited in list order)
dfs(1): visit 1
│  dfs(2): visit 2
│  │  dfs(4): visit 4 → no new neighbours → return
│  │  back at 2: nothing left → return
│  back at 1
│  dfs(3): visit 3
│  │  dfs(5): visit 5 → no new neighbours → return
│  │  back at 3 → return

order = 1 → 2 → 4 → 3 → 5 ✅   (dives to 4 before ever looking at 3)

Same graph, same visited set, different personality. BFS sweeps outward in circles; DFS tunnels down one corridor at a time. Both visit every reachable node exactly once, so both are O(V + E) time and O(V) space.

  • Feels like: ripples spreading in a pond.
  • Tool: a queue (collections.deque, popleft()).
  • Signature property: the first time you reach a node is via a fewest-steps path (unweighted graphs).
  • Iteration: a while queue loop — no recursion needed.
  • Feels like: exploring a maze with a ball of string.
  • Tool: recursion (or an explicit stack).
  • Signature property: trivially explores everything reachable — perfect for counting and marking.
  • Watch out: very deep graphs can hit Python’s recursion limit (~1000); BFS or an iterative stack avoids that.
  • “Fewest steps / shortest path / minimum moves” on an unweighted graph → BFS, no contest.
  • “Count the pieces / can we reach everything / mark all connected” → either works; DFS is often shorter, BFS avoids recursion limits.
  • “Process nodes level by level” (nearest first) → BFS.
  • When in doubt: both are O(V + E) — pick the one whose code you write with fewer bugs.
Quick check — which data structure gives BFS its level-by-level order?
Processing the oldest discovered node first drains each level before the next one starts. A stack would dive deep instead — that’s DFS.

Worked Problems

18.1 Number of Connected Components

🟡 L2 · Apply
Problem

You have n nodes labelled 0..n-1 and a list of undirected edges. Count how many separate connected pieces the graph breaks into. A connected component is a maximal set of nodes where every pair is linked (directly or indirectly).

Input / Output

n = 5, edges = [(0, 1), (1, 2), (3, 4)] → 2  (piece {0, 1, 2} and piece {3, 4})

Key Observation

One traversal from any node marks its entire piece — and nothing else. So: walk through all nodes in order; every time you meet one that no traversal has touched yet, you’ve discovered a brand-new piece — count it and traverse from it. The shared visited set guarantees each piece is counted exactly once.

Brute Force

Run a traversal from every node and collect reachable sets, then deduplicate identical sets. Correct, but it re-explores the same piece again and again. The shared visited set is what makes the final version touch each node once — the repeated work that disappears is re-exploring already-labelled pieces.

Optimal Idea

Outer loop over nodes + one BFS per unvisited node. Total work: every node is enqueued once, every edge inspected a constant number of times → O(V + E).

Python Solution
Python
from collections import deque

def count_components(n, edges):
    graph = {node: [] for node in range(n)}
    for a, b in edges:              # undirected → both directions
        graph[a].append(b)
        graph[b].append(a)

    visited = set()
    count = 0

    for node in range(n):
        if node in visited:
            continue
        count += 1                  # unvisited node → brand-new piece
        queue = deque([node])
        visited.add(node)
        while queue:                # BFS labels this whole piece
            cur = queue.popleft()
            for nb in graph[cur]:
                if nb not in visited:
                    visited.add(nb)
                    queue.append(nb)

    return count

print(count_components(5, [(0, 1), (1, 2), (3, 4)]))   # 2
Dry Run
Dry Run — n = 5, edges = [(0,1), (1,2), (3,4)]
node 0: unvisited → count = 1 → BFS gathers {0, 1, 2}
node 1: visited → skip
node 2: visited → skip
node 3: unvisited → count = 2 → BFS gathers {3, 4}
node 4: visited → skip

answer: 2 ✅
Complexity

Time O(V + E), space O(V + E) for the adjacency list and visited set.

Edge Cases
  • No edges at all → every node is its own component → answer n.
  • Everything connected → answer 1.
  • Self-loops or duplicate edges → harmless; the visited check absorbs them.
Common Mistakes
  • Building a directed adjacency list for an undirected problem (missing the graph[b].append(a) line).
  • Creating a fresh visited inside the BFS instead of sharing it across the outer loop → pieces get counted twice.
  • Counting nodes instead of pieces — the counter increments once per traversal launch, not per node.
Pattern Recognition
Pattern Insight

“Count the groups / pieces / circles / islands” → traverse from every unvisited node. The outer loop + visited set is a reusable skeleton: swap the BFS body and the same shell counts islands, friend circles, or provinces.

18.2 Count Islands (a grid problem — in disguise)

🟠 L3 · Recognize
Problem

A grid of 1s (land) and 0s (water). An island is a group of land cells connected horizontally or vertically (not diagonally). Count the islands.

Input / Output

grid = [[1,1,0,0], [1,1,0,0], [0,0,0,0], [1,0,1,1]] → 3

Key Observation
Pattern Insight — a grid IS a graph

Every cell is a node. Each cell connects to up to 4 neighbours: up, down, left, right — computed as (r+dr, c+dc) for (dr, dc) in [(-1,0), (1,0), (0,-1), (0,1)]. The grid boundary simply means some neighbours don’t exist. “Island” is just a fancy name for connected component of land cells — so 18.1’s skeleton applies directly.

Brute Force

Without a shared visited set, you’d re-explore the same island from every one of its cells. The visited set is what collapses the whole scan to one pass — each cell is enqueued at most once, ever.

Optimal Idea

Scan every cell. Land and unvisited? A new island: count it, then BFS from that cell to mark its whole body visited. The four-direction loop replaces the adjacency list — neighbours are computed, not stored.

Python Solution
Python
from collections import deque

def count_islands(grid):
    rows, cols = len(grid), len(grid[0])
    visited = set()
    count = 0

    def explore(r, c):
        """BFS over one island, marking every land cell visited."""
        queue = deque([(r, c)])
        visited.add((r, c))
        while queue:
            row, col = queue.popleft()
            for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:  # up, down, left, right
                nr, nc = row + dr, col + dc
                if (0 <= nr < rows and 0 <= nc < cols
                        and grid[nr][nc] == 1 and (nr, nc) not in visited):
                    visited.add((nr, nc))
                    queue.append((nr, nc))

    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 1 and (r, c) not in visited:
                count += 1          # unseen land cell → new island
                explore(r, c)

    return count
Dry Run
Dry Run — the 4 × 4 grid above
grid:              scan order: row by row, left to right

1 1 0 0            (0,0) land & unvisited → island #1 → BFS marks the whole
1 1 0 0               2 × 2 block: (0,0), (0,1), (1,0), (1,1)
0 0 0 0            row 2 is all water
1 0 1 1            (3,0) land & unvisited → island #2
                   (3,2) land & unvisited → island #3 → BFS marks (3,2), (3,3)

answer: 3 ✅
Complexity

Time O(R × C) — each cell enters the queue at most once. Space O(R × C) for the visited set (worst case: one giant island).

Edge Cases
  • All water → 0. All land → 1.
  • The classic LeetCode version uses "1"/"0" strings — only the comparison changes (== "1").
  • Single cell grid → 1 if land, else 0.
Common Mistakes
  • Moving in 8 directions when the problem says 4 (diagonals usually don’t connect).
  • Skipping the boundary check 0 <= nr < rows and 0 <= nc < cols → IndexError at the edges.
  • Marking cells visited only when dequeued — the same cell can be enqueued up to 4 times. Mark on enqueue.
  • Mutating the grid but also keeping a visited set — pick one way to record “seen”; using both is noise (either alone works).
Pattern Recognition
Pattern Insight

Grid + “connected region of cells” → graph traversal with computed 4-direction neighbours. Once you see the grid-as-graph mapping, flood fill, max island area, and maze reachability are all the same ~20 lines with a different counting line.

Quick Drills

Drill — Path: Does a Route Exist?

🟢 L1

Given a graph, a start and a target: is there any path between them?

Show Hint

Plain BFS — with one upgrade: the moment you see the target, you may stop early. No need to finish the level.

Show Solution
Python
from collections import deque

def path_exists(graph, start, target):
    if start == target:
        return True
    visited = {start}
    queue = deque([start])
    while queue:
        node = queue.popleft()
        for nb in graph[node]:
            if nb == target:
                return True          # early exit
            if nb not in visited:
                visited.add(nb)
                queue.append(nb)
    return False                     # queue drained, target never seen

O(V + E) worst case, often far less thanks to the early exit.

Drill — Flood Fill

🟡 L2

Like a paint-bucket tool: starting at (sr, sc), recolour every cell connected to it (4-directionally) that shares its original colour. image = [[1,1,1],[1,1,0],[1,0,1]], sr=1, sc=1, new=2 → [[2,2,2],[2,2,0],[2,0,1]].

Show Hint

It’s island-BFS, but the “land” condition is image[nr][nc] == old_colour. One nasty trap hides in the parameters — think about new == old.

Show Solution
Python
from collections import deque

def flood_fill(image, sr, sc, new_color):
    rows, cols = len(image), len(image[0])
    old = image[sr][sc]
    if old == new_color:
        return image                # guard! otherwise the loop never ends

    queue = deque([(sr, sc)])
    image[sr][sc] = new_color
    while 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 < rows and 0 <= nc < cols and image[nr][nc] == old:
                image[nr][nc] = new_color   # recolour = mark visited
                queue.append((nr, nc))
    return image

Here the grid itself plays the visited set (a cell no longer has the old colour → never re-enqueued). That’s why the old == new_color guard is life-or-death: without it, recoloured cells still look “unvisited” and BFS spins forever.

Drill — Shortest Path in an Unweighted Grid (BFS)

🟡 L2

Grid of 0s (open) and 1s (walls). Find the fewest steps from top-left to bottom-right moving in 4 directions, or -1 if blocked.

Show Hint

BFS explores in waves, and the wave number is the distance. Carry dist inside the queue entries: (row, col, dist).

Show Solution
Python
from collections import deque

def shortest_path(grid):
    rows, cols = len(grid), len(grid[0])
    if grid[0][0] == 1 or grid[rows - 1][cols - 1] == 1:
        return -1                       # start or end is a wall

    queue = deque([(0, 0, 0)])          # (row, col, steps so far)
    visited = {(0, 0)}
    while queue:
        r, c, dist = queue.popleft()
        if (r, c) == (rows - 1, cols - 1):
            return dist                 # first arrival = fewest steps
        for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
            nr, nc = r + dr, c + dc
            if (0 <= nr < rows and 0 <= nc < cols
                    and grid[nr][nc] == 0 and (nr, nc) not in visited):
                visited.add((nr, nc))
                queue.append((nr, nc, dist + 1))
    return -1

On [[0,0,0],[1,1,0],[0,0,0]] the answer is 4: (0,0) → (0,1) → (0,2) → (1,2) → (2,2). Why is the first arrival optimal? Because BFS finishes the distance-3 wave entirely before touching any distance-4 cell — there is simply no shorter way to arrive.

Drill — Simple Traversal: Collect Everything Reachable

🟢 L1

Given a graph and a start node, return the set of all nodes reachable from it.

Show Solution
Python
def reachable_from(graph, start):
    visited = {start}
    stack = [start]                  # a list works as a stack → iterative DFS
    while stack:
        node = stack.pop()
        for nb in graph[node]:
            if nb not in visited:
                visited.add(nb)
                stack.append(nb)
    return visited

Swap the stack for a deque + popleft() and you get the BFS flavour — the visited set does the real work either way.

The Graph Radar

Almost every beginner graph problem is one of three questions wearing a costume: can I get there? (reachability), how many pieces? (components), how few steps? (shortest path, unweighted). All three resolve to the same pipeline:

Reachability? Pieces? Fewest steps?
→
Adjacency list — or 4-direction grid neighbours
→
BFS (queue) or DFS (recursion) + visited
→
Count / mark / return distance
Pattern Insight

“Connected pieces → traverse from every unvisited node” and “fewest steps in an unweighted graph → BFS” are the two load-bearing ideas of this chapter. Everything else — islands, flood fill, mazes, rotten oranges — is these two ideas with a different definition of “neighbour”.

When edges carry weights, “fewest steps” is no longer “cheapest route” — weighted shortest paths need heavier machinery that lives beyond this beginner book.

What You Learned

  • A graph is nodes + edges; edges may be undirected or directed, unweighted or weighted (this book: unweighted).
  • The Python way to store a graph is an adjacency list — a dict of lists built in one loop, with both directions for undirected graphs.
  • BFS = queue + visited set → visits level by level; the level number is the distance, so BFS solves fewest-steps on unweighted graphs.
  • DFS = recursion → dive deep, backtrack; the call stack is the backtrack mechanism.
  • The visited set is what makes traversal terminate on graphs with cycles — mark nodes on enqueue.
  • A grid is a graph in disguise: cells are nodes, and neighbours are the up to 4 cells (r±1, c), (r, c±1).
  • Counting pieces = one outer loop over nodes + a traversal per unvisited node, sharing one visited set.

Pattern Recognition

Hear the words “connected”, “group of cells”, “reachable”, “fewest moves”, “number of islands/circles/regions” → think graph traversal. Build the neighbour relation (edge list → adjacency list, or grid → 4 directions), pick BFS when distance matters and DFS/BFS when you just need to touch everything, and never travel without the visited set. No fancy algorithms are needed at this level — the beginner wins come from seeing the graph inside the story.

Complexity Summary

TaskTimeExtra space
BFS traversalO(V + E)O(V)
DFS traversalO(V + E)O(V)
Connected componentsO(V + E)O(V + E)
Count islands (R × C grid)O(R · C)O(R · C)
BFS shortest path, unweightedO(V + E)O(V)

Common Mistakes

  • Forgetting the reverse edge for undirected graphs — the traversal then can’t get home and pieces multiply.
  • No visited set (or marking too late) → nodes re-enter the queue; with cycles, the loop never ends.
  • Missing boundary checks in grids: every neighbour needs 0 <= nr < rows and 0 <= nc < cols before indexing.
  • Connecting diagonals when the problem says 4-directional.
  • Deep recursion on long paths → Python’s recursion limit (~1000). Use BFS or an explicit stack.
  • Re-using a fresh visited per traversal when counting pieces — one shared set per call is the whole point.

Mini Quiz

Q1. Your BFS has no visited set and the graph contains a cycle. What happens?
Nodes on the cycle keep re-enqueueing each other. The visited set breaks the cycle by letting each node enter the queue exactly once.
Q2. “Fewest number of moves from A to B” on an unweighted graph — which traversal guarantees it?
BFS finishes every distance-k node before any distance-(k+1) node, so the first time you reach B is via a fewest-moves path. DFS tunnels and can reach B the long way first.
Q3. In island problems with 4-directional movement, how many neighbour candidates does each cell check?
(r±1, c) and (r, c±1) — four candidates, each guarded by a boundary check. Diagonals would be 8-directional, a different problem.
Q4. What is the total time of one BFS over a graph with V nodes and E edges?
Each node is enqueued once (visited set), and each edge is examined a constant number of times — once from each endpoint for undirected graphs.

Practice Problems

P18-A · Is the Graph Connected?

🟢 L1 · Understand

Given n nodes and undirected edges: does one piece contain everything? (Same input as 18.1, different question: is the component count exactly 1?)

Show Hint

One BFS from node 0. What should the size of the visited set be at the end?

Show Approach

BFS from node 0 marks everything reachable from it. The graph is connected exactly when that set contains all n nodes — no outer loop needed.

Show Solution
Python
from collections import deque

def is_connected(n, edges):
    graph = {i: [] for i in range(n)}
    for a, b in edges:
        graph[a].append(b)
        graph[b].append(a)

    visited = {0}
    queue = deque([0])
    while queue:
        cur = queue.popleft()
        for nb in graph[cur]:
            if nb not in visited:
                visited.add(nb)
                queue.append(nb)

    return len(visited) == n

O(V + E). Edge case: n = 1 with no edges → True (a single node is trivially connected).

P18-B · Max Area of Island

🟡 L2 · Apply

Same grid setting as 18.2, but instead of counting islands, report the number of cells in the largest island (0 if there is no land).

Show Hint

The island-finding BFS already touches every cell of an island exactly once. What could you count during those touches?

Show Approach

Each time you dequeue a cell, add 1 to that island’s running size. Track the maximum over all islands. The skeleton is 18.2 with a counter.

Show Solution
Python
from collections import deque

def max_island_area(grid):
    rows, cols = len(grid), len(grid[0])
    visited = set()

    def area(r, c):
        size = 0
        queue = deque([(r, c)])
        visited.add((r, c))
        while queue:
            row, col = queue.popleft()
            size += 1                      # each cell counted once
            for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
                nr, nc = row + dr, col + dc
                if (0 <= nr < rows and 0 <= nc < cols
                        and grid[nr][nc] == 1 and (nr, nc) not in visited):
                    visited.add((nr, nc))
                    queue.append((nr, nc))
        return size

    best = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 1 and (r, c) not in visited:
                best = max(best, area(r, c))
    return best

Time O(R · C) — each cell still enters a queue at most once.

P18-C · Distance From Start to Every Node

🟡 L2 · Apply

For an unweighted graph and a start node, return a dict mapping every reachable node to its fewest-steps distance. Unreachable nodes are simply absent.

Show Hint

Replace the visited set with a dist dict — a node being in the dict means “visited”, and its value is the wave number it was discovered in.

Show Approach

When you first discover nb from node, its distance is dist[node] + 1 — because BFS discovers level by level, first discovery = shortest distance.

Show Solution
Python
from collections import deque

def distances(graph, start):
    dist = {start: 0}                  # doubles as the visited set
    queue = deque([start])
    while queue:
        node = queue.popleft()
        for nb in graph[node]:
            if nb not in dist:
                dist[nb] = dist[node] + 1
                queue.append(nb)
    return dist

# distances(graph, 1) → {1: 0, 2: 1, 3: 1, 4: 2, 5: 2}

O(V + E). Note how the dict from this drill is exactly what the grid shortest-path drill stores inside its queue tuples.

Challenge Problem

Challenge · Rotting Oranges (multi-source BFS)

🟠 L3 · Recognize

A grid holds 0 (empty), 1 (fresh orange), 2 (rotten orange). Every minute, rot spreads from each rotten orange to its 4-directional fresh neighbours. Return the number of minutes until no fresh orange remains, or -1 if some orange can never rot. [[2,1,1],[1,1,0],[0,1,1]] → 4.

Show Hint

This is BFS — but the wave doesn’t start at one cell. Several rotten oranges start rotting simultaneously. What if they all entered the queue at minute 0?

Show Approach

Seed the queue with all rotten oranges (each carrying minute 0) and count the fresh ones. Run the standard 4-direction BFS; each newly rotted orange decrements the fresh counter and enters the queue one minute later. The answer is the minute of the last orange rotted — or -1 if fresh oranges survive.

Show Solution
Python
from collections import deque

def orange_rotting(grid):
    rows, cols = len(grid), len(grid[0])
    queue = deque()
    fresh = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 2:
                queue.append((r, c, 0))    # every rotten cell = a source
            elif grid[r][c] == 1:
                fresh += 1

    minutes = 0
    while queue:
        r, c, minutes = queue.popleft()
        for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                grid[nr][nc] = 2           # rot it = mark visited
                fresh -= 1
                queue.append((nr, nc, minutes + 1))

    return -1 if fresh > 0 else minutes

The grid itself is the visited set (a cell stops being 1 the moment it rots). On the example, the last orange to rot — (2, 2) — turns at minute 4 ✅. No fresh oranges at all? The queue drains with minutes = 0, correctly. O(R · C).

Revision Checklist

PART 19

Greedy Thinking Basics

Make the best local choice — then make sure it isn’t a trap.

Greedy is the art of committing. At every step you take the choice that looks best right now, and you never go back to reconsider. When the problem structure supports it, greedy gives you the fastest algorithms you will ever write — one sort and one scan, O(n log n) total. When it doesn’t, greedy fails silently: the code runs, the small tests pass, and the answer is quietly wrong. So this chapter teaches two things with equal weight: how to solve greedy problems, and how to interrogate a greedy idea before you trust it.

The greedy mindset

Concept

Greedy = make the best local choice and see whether it leads to a valid global solution. No backtracking, no exploring alternatives — just a rule, applied in a smart order, one decision at a time. The “see whether” part is not decoration: it is the whole discipline of this chapter.

1

Name the choice. What is being decided at each step, and what does “best” mean for it? (“Pick the activity that ends earliest.”)

2

Create the order. Almost always a sort — the sort key is the greedy strategy. Sorting by end time means each step only ever compares against a boundary.

3

Argue safety (or hunt a counterexample). Can taking the local best ever block a better global answer? If yes → wrong rule. If you can’t break it after honest attempts → ship it.

How to recognize a greedy problem

Two signals, and they usually appear together:

  • Sorting creates the choice order. The moment you imagine the input sorted, each decision becomes obvious. Statements like “maximum number of …”, “minimum number of … to cover/assign”, “pair up … at least cost” almost always hide this.
  • The local optimum seems safe. Taking the best-looking item now doesn’t damage future options — e.g. choosing the interval that ends first leaves the most room for everything else. If you can say why it’s safe in one sentence, greedy is alive.

The anti-signal matters too: if the value of a choice depends on future choices (each decision reshapes what “best” even means for the rest), greedy collapses — that territory belongs to search or dynamic programming, well beyond this book.

When greedy works — and when it betrays you

The classic classroom example is making change. Same task, two coin systems, opposite outcomes:

Rule: always take the biggest coin that fits. Target 6:

Dry Run — coins [1, 2, 5], target 6
remaining 6 → take 5 → remaining 1
remaining 1 → take 1 → remaining 0

greedy: 5 + 1 = 2 coins ✅   (check the alternatives: 2+2+2 = 3 coins — greedy wins)

Why is it safe here? A plain-language exchange argument: an optimal solution never needs three 1s (three 1s → a 1 and a 2: fewer coins), and never needs three 2s (2+2+2 = 6 → a 5 and a 1: fewer coins). So any optimal solution uses at most two 1s and at most two 2s — meaning everything else must be 5s. That is exactly what greedy takes. The local choice can always be part of some optimal answer.

Same rule, one coin changed — and it breaks:

Dry Run — coins [1, 3, 4], target 6
remaining 6 → take 4 → remaining 2
remaining 2 → 4 too big, 3 too big → take 1 → remaining 1
remaining 1 → take 1 → remaining 0

greedy:  4 + 1 + 1 = 3 coins ❌
optimal: 3 + 3     = 2 coins ✅

Greedy’s first move (the 4) felt best locally but destroyed the structure that made 3 + 3 possible. One counterexample is enough — the rule is dead for this coin system.

⚠️ Common Mistake — treating greedy as a law of nature

Coins [1, 3, 4], target 6. Greedy grabs the 4 and limps home with 4 + 1 + 1 — 3 coins. The optimum is 3 + 3 — 2 coins. The code ran perfectly. Every small test with target 1–5 passes. The answer is still wrong. Moral: a greedy rule is a hypothesis, not a proof. Always run the counterexample hunt (last section of this chapter).

So when does greedy work? When you can argue the exchange test: swapping the greedy choice into any optimal solution never makes things worse. For coin systems like [1, 2, 5] the swap works; for [1, 3, 4] it visibly doesn’t. The three problems below all pass the exchange test — and we’ll say why for each, not just that.

Quick check — coins [1, 3, 4], target 6: how many coins does the “biggest coin first” greedy use?
4 + 1 + 1 = 6 → three coins, while 3 + 3 reaches the target in two. A textbook reminder that locally-best ≠ globally-best.

Worked Problems

19.1 Activity Selection (Maximum Non-Overlapping Intervals)

🟠 L3 · Recognize
Problem

Given intervals [start, end], choose the maximum number of activities with no overlapping times (one person, one room). Touching is allowed: an activity may start exactly when the previous one ends.

Input / Output

[(1,3), (2,4), (3,5), (0,7), (5,8)] → 3  (e.g. (1,3), (3,5), (5,8))

Key Observation

Whichever activity you run first, you want it to finish as early as possible — an early finish leaves the most timeline for everyone else. So: sort by end time, sweep once, and take every activity that starts at or after the last one’s end. The sort key is the strategy.

Brute Force

Try every subset of activities and check the best compatible one — O(2ⁿ), hopeless beyond n ≈ 20. The greedy sweep replaces exponential subset-exploration with a single sorted pass: the work that disappears is reconsidering activities already rejected.

Why sorting by end time (and not something else)

The exchange argument: take any optimal schedule and look at the activity in it that finishes first. The globally earliest-finishing activity must finish no later — so it can replace that member without colliding with anything that follows. Greedy’s first pick is therefore always safe, and the argument repeats for the remaining timeline. The other sort keys genuinely fail (see the reveal below).

Show why sorting by start or duration fails

By start: [(0,10), (1,2), (3,4)] — start-order picks (0,10) first and blocks both short ones → 1 activity. Optimum is 2: (1,2), (3,4).

By duration: [(1,4), (4,5), (5,6), (6,9)] — shortest-first grabs the two 1-length middles, then (6,9) → 3 activities. Optimum is 4: (1,4), (4,5), (5,6), (6,9) chain perfectly. Only end time survives every tiny case.

Optimal Idea

Keep one variable: last_end, the finish time of the last activity taken. An activity is compatible iff start >= last_end. One pass over the end-sorted list.

Python Solution
Python
def max_activities(intervals):
    intervals.sort(key=lambda iv: iv[1])   # sort by END time — the strategy

    count = 0
    last_end = float("-inf")
    for start, end in intervals:
        if start >= last_end:              # compatible → take it
            count += 1
            last_end = end
    return count

print(max_activities([(1, 3), (2, 4), (3, 5), (0, 7), (5, 8)]))   # 3
Visual
[1,3]
[2,4]
[3,5]
[0,7]
[5,8]
End-sorted order, scale 0–8 · highlighted = the 3 activities the greedy sweep keeps
Dry Run
Dry Run — after sorting by end: (1,3), (2,4), (3,5), (0,7), (5,8)
(1,3): 1 ≥ -inf ✅ take → count 1, last_end = 3
(2,4): 2 ≥ 3 ❌ skip
(3,5): 3 ≥ 3 ✅ take → count 2, last_end = 5   (touching counts!)
(0,7): 0 ≥ 5 ❌ skip
(5,8): 5 ≥ 5 ✅ take → count 3, last_end = 8

answer: 3 ✅   (kept: (1,3), (3,5), (5,8))
Complexity

Time O(n log n) — the sort dominates; the sweep is O(n). Space O(1) extra (beyond sorting).

Edge Cases
  • Empty list → 0.
  • All overlapping → 1.
  • Touching endpoints (start == last_end) → compatible here; if your problem forbids touching, switch to start > last_end — decide deliberately.
Common Mistakes
  • Sorting by start time or duration — see the counterexamples above; both fail.
  • Using > when the problem allows touching intervals (or vice versa) — off-by-one on the boundary.
  • Forgetting to sort at all and iterating in input order — greedy needs its choice order.
Pattern Recognition
Pattern Insight

“Maximum number of non-overlapping X” → sort by end, sweep once. Meeting rooms scheduling, task selection, video watching, arrow/balloon variants — the sort key (end time) is the entire algorithm; the loop is bookkeeping.

19.2 Assign Cookies

🟡 L2 · Apply
Problem

Child i has a greed factor g[i] and is content only with a cookie of size >= g[i]. Each cookie (size s[j]) feeds at most one child. Maximize the number of content children.

Input / Output

g = [1, 2, 3], s = [1, 1] → 1  ·  g = [1, 2], s = [1, 2, 3] → 2

Key Observation

Two sorted orders turn the problem into a polite queue: sort children by greed (least greedy first) and cookies by size (smallest first). Then serve the smallest cookie that satisfies the least-greedy unserved child. Why safe? Giving a bigger cookie to an easy child wastes it — that cookie might have been some other child’s only chance. Small cookies to small greeds never blocks anyone.

Brute Force

Try all child–cookie matchings and keep the best — factorial matchings. Greedy pairing after sorting collapses it to one two-pointer pass: the work that disappears is reconsidering pairings already ruled out.

Optimal Idea

Walk the cookies (not the children) with one pointer. A cookie that can’t satisfy the current smallest-greed child can’t satisfy anyone else either (everyone left is greedier) → throw the cookie away and move on. When it fits, serve and advance the child pointer.

Python Solution
Python
def find_content_children(children, cookies):
    children.sort()                  # least greedy first
    cookies.sort()                   # smallest first

    child = 0
    for cookie in cookies:
        if child < len(children) and children[child] <= cookie:
            child += 1               # this cookie found its child
    return child                     # children[0:child] got served

print(find_content_children([1, 2, 3], [1, 1]))    # 1
print(find_content_children([1, 2], [1, 2, 3]))    # 2
Dry Run
Dry Run — g = [1, 2, 3], s = [1, 1]
sorted: children [1, 2, 3], cookies [1, 1]

cookie 1: child 0 needs ≥ 1 → 1 ≤ 1 ✅ serve → child = 1
cookie 1: child 1 needs ≥ 2 → 2 ≤ 1 ❌ cookie wasted, move on

no cookies left → answer 1 ✅
Complexity

Time O(n log n + m log m) for the two sorts; the merge-style pass is O(n + m). Space O(1) extra.

Edge Cases
  • No cookies or no children → 0.
  • Equal sizes/greed values → <= handles ties (a cookie of exactly the greed size satisfies).
  • Leftover cookies or children are simply unused — the answer only counts served children.
Common Mistakes
  • Serving greediest children first — a huge cookie gets spent on an easy child and a hard child starves.
  • Looping over children and searching for a cookie each time — works but obscures the two-pointer elegance and invites index bugs.
  • Using < instead of <= — a cookie exactly equal to the greed factor does satisfy.
Pattern Recognition
Pattern Insight

“Best pairing between two groups” → sort both, walk with two pointers. Boats carrying pairs, matching widths to heights, pairing smallest-with-smallest — whenever the sorted orders make each decision obvious, you’re in greedy two-pointer country (the same pattern you met with pair-sums on sorted arrays).

19.3 Jump Game (track the furthest reach)

🟡 L2 · Apply
Problem

nums[i] is the maximum jump length from index i. Starting at index 0, can you reach the last index?

Input / Output

[2, 3, 1, 1, 4] → True  ·  [3, 2, 1, 0, 4] → False

Key Observation

You don’t need actual jump paths — only how far the frontier of reachability extends. The set of reachable indices is always a contiguous prefix: if index j is reachable, so is everything before it (hop less). So sweep left to right keeping one number, furthest — the rightmost index reachable so far — and update it with i + nums[i] from every index you can stand on.

Brute Force

Explore every jump path with recursion — exponential paths. The frontier sweep visits each index once: the work that disappears is enumerating paths; only the boundary matters.

Optimal Idea

One pass with one rule: if the current index lies beyond furthest, there’s an uncrossable gap → False. Otherwise extend furthest. Reaching the last index is then guaranteed the moment the sweep passes it.

Python Solution
Python
def can_reach_end(nums):
    furthest = 0                          # rightmost index reachable so far
    for i, jump in enumerate(nums):
        if i > furthest:                  # this index was never reachable
            return False                  # → an uncrossable gap
        furthest = max(furthest, i + jump)
    return True                           # the sweep covered the last index

print(can_reach_end([2, 3, 1, 1, 4]))     # True
print(can_reach_end([3, 2, 1, 0, 4]))     # False
Dry Run
Dry Run — nums = [2, 3, 1, 1, 4]
i=0, jump 2 → 0 ≤ furthest 0 ✅ → furthest = max(0, 0+2) = 2
i=1, jump 3 → 1 ≤ 2 ✅ → furthest = max(2, 1+3) = 4
i=2, jump 1 → 2 ≤ 4 ✅ → furthest = max(4, 2+1) = 4
i=3, jump 1 → 3 ≤ 4 ✅ → furthest = max(4, 3+1) = 4
i=4, jump 4 → 4 ≤ 4 ✅ → furthest = max(4, 4+4) = 8
sweep ends → True ✅
Dry Run — nums = [3, 2, 1, 0, 4]
i=0 → furthest = 3
i=1 → furthest = max(3, 3) = 3
i=2 → furthest = max(3, 3) = 3
i=3 → furthest = max(3, 3) = 3     (the 0 at index 3 adds nothing)
i=4 → 4 > 3 ❌ index 4 unreachable → return False ✅
Complexity

Time O(n) — one pass. Space O(1). (Why greedy is safe: reachability is a prefix — the frontier number summarises all paths at once, so no path choices are ever thrown away.)

Edge Cases
  • Single element [0] → True (you start on the last index).
  • First element 0 with n > 1 → False immediately (stuck at index 0).
  • A 0 mid-array is fine unless the frontier can’t step past it.
Common Mistakes
  • Checking i > furthest after updating instead of before — you’d validate an index with its own jump.
  • Recursing over paths “to be safe” — exponential blow-up on staircase inputs.
  • Confusing this with the follow-up that asks for the minimum number of jumps — same frontier idea, different counter.
Pattern Recognition
Pattern Insight

“Can I reach / cover the end?” → track the frontier, not the paths. Reachability along a line, gas stations, video stitching — whenever “reachable set” is a contiguous range, one boundary variable beats any search.

Quick Drills

Drill — Minimum Coins (a system where greedy IS valid)

🟢 L1

Coin system [1, 2, 5], amount 28. Biggest coin first — valid here because no smaller-coin combination ever beats a bigger coin (see the exchange argument in the tabs above).

Show Solution
Python
def min_coins_greedy(amount, coins=(1, 2, 5)):
    count = 0
    for coin in sorted(coins, reverse=True):   # biggest first
        count += amount // coin                # take as many as fit
        amount = amount % coin                 # carry the remainder on
    return count

print(min_coins_greedy(28))                    # 7 → 5+5+5+5+5+2+1

Trace: 28 // 5 = 5 fives, remainder 3 → 3 // 2 = 1 two, remainder 1 → one 1. Total 7 coins ✅. The // and % pair replaces the one-coin-at-a-time loop — same greedy, less code. Just remember: swap in coins [1, 3, 4] and this function confidently returns a wrong answer.

Drill — Simple Scheduling: Can You Attend Every Meeting?

🟢 L1

Given meeting intervals, can a person attend all of them? (The yes/no mirror of 19.1: instead of maximizing, just detect any clash.) [(0,30), (5,10), (15,20)] → False · [(7,10), (2,4)] → True.

Show Hint

In start-sorted order, a clash can only happen between neighbours: if each meeting starts at or after the previous one ends, nothing overlaps anywhere.

Show Solution
Python
def can_attend_all(intervals):
    intervals.sort()                          # by start time (tuples sort naturally)
    for i in range(1, len(intervals)):
        if intervals[i][0] < intervals[i - 1][1]:
            return False                      # starts before the previous ends
    return True

Note the sort key: for detection the start order is enough (clashes show up as adjacent inversions); for selection in 19.1 only end order worked. Choosing the key is the thinking part of every interval problem.

How to sanity-check a greedy idea

Greedy answers arrive with a dangerous feeling of obviousness. Before you code — and definitely before you claim correctness in an interview — run this four-step check:

1

State the rule in one sentence. “Always take the X that Y first.” If you can’t phrase it that crisply, you don’t have a greedy algorithm yet — you have a vibe.

2

Run the exchange test. Could swapping the greedy choice for a different one ever leave a better remainder? If you can argue “never” (like end-times or the [1,2,5] coins), the rule is safe. If you can’t argue it, don’t panic — just go hunting.

3

Hunt a tiny killer case by hand. Five items or fewer. Deliberately try the nasty shapes: ties, touching endpoints, one huge item vs two small ones, an all-equal input, the coins-[1,3,4] pattern (a locally tempting choice that destroys a better pairing). If a hand case beats your rule → the rule is dead; change the sort key or change techniques.

4

Cross-check cheaply when unsure. For small inputs, run a brute force alongside the greedy and compare on a handful of random cases. It won’t prove correctness, but it finds counterexamples fast — which is what step 3 is really about.

Tip

In an interview, saying “my greedy works because taking the earliest-finishing interval can always replace the first item of an optimal schedule” is worth more than the code. Naming why the local choice is safe is what separates a greedy solution from a lucky one.

Pattern Insight

The greedy radar, in one breath: “maximum number of things that fit → sort by end and sweep” · “best pairing of two groups → sort both, two pointers” · “can I reach/cover the end → track the frontier” · “make change with a friendly coin system → biggest coin first (after the counterexample hunt!)”. And the universal prefix: prove the local choice is safe, or find the case where it isn’t.

What You Learned

  • Greedy = make the best local choice and see whether it leads to a valid global solution — no backtracking, one sort + one scan.
  • Recognition signals: sorting creates the choice order, and the local optimum is arguably safe.
  • Greedy is not always correct: coins [1, 3, 4], target 6 → greedy gives 3 coins (4+1+1), optimum is 2 (3+3). One counterexample kills a rule.
  • Greedy is provably correct when the exchange test passes — the local choice can always join some optimal solution.
  • Activity selection sorts by end time; assign-cookies sorts both lists and serves smallest-to-smallest; jump game tracks one furthest-reach frontier.
  • Every greedy claim deserves the four-step sanity check — especially the tiny hand-made killer case.

Pattern Recognition

See “maximum number of non-overlapping …” → sort by end and sweep. See “pair up / assign / serve with least waste” → sort both sides, two pointers, smallest-to-smallest. See “can I reach / cover / get through?” on a line → maintain the frontier. See “fewest coins/steps with simple structure” → biggest-first, but only after the counterexample hunt comes up empty. In every case the deliverable is the same: the one-sentence reason the local choice is safe.

Complexity Summary

ProblemTimeExtra space
Activity selection (max non-overlapping)O(n log n)O(1)
Assign cookiesO(n log n + m log m)O(1)
Jump game (furthest reach)O(n)O(1)
Minimum coins, greedy-valid systemO(c log c) for c coin typesO(1)
Can attend all meetingsO(n log n)O(1)

Common Mistakes

  • Trusting a greedy rule because it worked on the examples — the coins [1,3,4] trap passes every small test until target 6.
  • Picking the wrong sort key: end time for interval selection, start time for interval detection — mixing them up breaks both.
  • Boundary confusion: decide whether start == last_end is compatible and use >= or > deliberately.
  • Forgetting to sort — greedy is a rule plus an order; without the order it’s just a coin flip.
  • Claiming “obviously optimal” without stating the safety argument — in interviews this reads as a guess.

Mini Quiz

Q1. In one sentence, greedy means…
No exploration, no revisiting — one committed decision per step. (Exploring all options is brute force; caching subproblems is DP.)
Q2. Coins [1, 3, 4], target 6. The greedy “biggest coin first” answer uses how many coins, and what is the optimum?
Greedy takes 4 immediately and strands itself: 4+1+1 = 3 coins. The lesson isn’t “greedy is bad” — it’s “greedy is a hypothesis; hunt the counterexample.”
Q3. For “maximum number of non-overlapping intervals”, the correct sort key is…
Finishing earliest leaves the most room for the rest — and the exchange argument guarantees that choice is always safe. Start- and duration-sorted orders both have tiny counterexamples.
Q4. The jump game keeps exactly one number throughout the sweep. What is it?
furthest = max(furthest, i + nums[i]) — if the current index ever passes it, there’s an uncrossable gap. Reachability is a prefix, so one boundary number captures everything.

Practice Problems

P19-A · Best Time to Buy and Sell Stock

🟢 L1 · Understand

prices[i] is a stock price on day i. One buy, one later sell — maximize profit, or 0 if no profit is possible. [7, 1, 5, 3, 6, 4] → 5 (buy at 1, sell at 6).

Show Hint

The best sale on day i uses the cheapest price seen before day i. Can you track that in one variable while sweeping?

Show Approach

Sweep once keeping min_so_far. At each price, the best profit ending here is price - min_so_far; then update min_so_far. Greedy-safe because each day’s best sale genuinely only needs the running minimum.

Show Solution
Python
def max_profit(prices):
    best = 0
    min_so_far = prices[0]
    for price in prices[1:]:
        best = max(best, price - min_so_far)
        min_so_far = min(min_so_far, price)
    return best

O(n) time, O(1) space. On the example: min drops to 1, profit peaks at 6 - 1 = 5 ✅.

P19-B · Boats to Save People

🟡 L2 · Apply

Each boat carries at most two people whose weights sum to at most limit. Everyone people[i] must be carried; find the minimum number of boats. people = [3, 2, 2, 1], limit = 3 → 3.

Show Hint

Sort. The heaviest person must board someone’s boat — who is their least-expensive companion?

Show Approach

Two pointers at the ends. The heaviest always takes a boat; pair them with the lightest if the sum fits (the lightest is the only one that might squeeze in — anyone else would definitely overflow). Otherwise the heaviest sails alone.

Show Solution
Python
def num_rescue_boats(people, limit):
    people.sort()
    i, j = 0, len(people) - 1
    boats = 0
    while i <= j:
        if people[i] + people[j] <= limit:
            i += 1                  # lightest person joins the boat
        j -= 1                      # heaviest person always boards
        boats += 1
    return boats

Trace [1, 2, 2, 3], limit 3: 1+3 ✗ → boat for 3; 1+2 ✅ → boat for 1+2; 2 alone → boat. Total 3 ✅. O(n log n).

P19-C · Largest Perimeter Triangle

🟡 L2 · Apply

Pick three stick lengths forming a triangle with the largest perimeter; return 0 if impossible. For sorted sides a ≥ b ≥ c, the only real condition is a < b + c. [2, 1, 1] → 0  ·  [10, 2, 1, 1] → 0  ·  [3, 4, 5] → 12.

Show Hint

Sort descending. If even the two next-largest sticks can’t extend the largest one, can any smaller pair do it?

Show Approach

Greedy: the largest perimeter wants the largest sticks. Scan descending triples; the first nums[i] < nums[i+1] + nums[i+2] is the answer — no need to check smaller pairs, they only shrink the perimeter.

Show Solution
Python
def largest_perimeter(nums):
    nums.sort(reverse=True)
    for i in range(len(nums) - 2):
        if nums[i] < nums[i + 1] + nums[i + 2]:
            return nums[i] + nums[i + 1] + nums[i + 2]
    return 0

O(n log n). Sanity check with the drill: on [5, 3, 2] — 5 < 3 + 2? No (equal, and equality is a flat line) → 0 ✅.

Challenge Problem

Challenge · Minimum Number of Arrows to Burst Balloons

🟠 L3 · Recognize

Balloons are horizontal intervals [start, end]; an arrow shot vertically at position x bursts every balloon with start <= x <= end. Find the minimum arrows to burst them all. [[10,16],[2,8],[1,6],[7,12]] → 2.

Show Hint

It’s 19.1 wearing a costume. If a balloon must be burst by some arrow, which arrow position is the smartest one to spend on it?

Show Approach

Sort by end. Shoot each arrow at the end of the first not-yet-burst balloon — the latest position that still bursts it, leaving maximal room to also catch later balloons. A later balloon needs a new arrow only when its start is strictly beyond the last arrow position.

Show Solution
Python
def find_min_arrows(points):
    if not points:
        return 0
    points.sort(key=lambda b: b[1])        # sort by end, like 19.1

    arrows = 1
    last_arrow = points[0][1]              # shoot at the first balloon's end
    for start, end in points[1:]:
        if start > last_arrow:             # beyond the current arrow → needs a new one
            arrows += 1
            last_arrow = end
    return arrows

Trace the example, end-sorted: [1,6],[2,8],[7,12],[10,16]. Arrow at 6 bursts [1,6] and [2,8] (2 ≤ 6 ≤ 8); [7,12] starts at 7 > 6 → arrow at 12, which also bursts [10,16]. Total 2 ✅. O(n log n) — one more proof that sort by end + sweep is the master key of interval greed.

Revision Checklist

PART 20

Interval Problems

Meetings, bookings, time ranges — sort by start, then scan. One recipe solves a whole family of problems.

An interval is just a pair [start, end] — a slice of time or space. Video watch windows, meeting times, cab bookings, bridge-closure hours: the data shape is identical, and so is the solution shape. Almost every interval problem follows a two-step recipe: sort the intervals, then sweep left to right asking one question at each boundary. This part builds that recipe from the ground up, using horizontal bar pictures — the way you'd draw it on a whiteboard.

Concept — intervals as data

Represent an interval as a two-element list: [1, 3] means "starts at 1, ends at 3". A list of intervals is a list of pairs: [[1, 3], [2, 6], [8, 10]]. Numbers can be minutes, hours, or any integer unit — the logic never changes. We draw them as horizontal bars over a shared timeline:

[1,3]
[2,6]
[8,10]

Three intervals on one timeline (0..10). [1,3] and [2,6] collide — their bars share the 2..3 region. [8,10] lives alone.

Step 0 — always sort the intervals first

Given intervals in random order, almost nothing can be decided locally: the interval that clashes with [2, 6] might sit anywhere in the list. Sorting by start fixes that — every potential conflict becomes a neighbour:

Python
intervals = [[8, 10], [1, 3], [2, 6]]

intervals.sort(key=lambda iv: iv[0])    # sort by start
# → [[1, 3], [2, 6], [8, 10]]

intervals.sort()                         # also works: pairs compare start first,
# → [[1, 3], [2, 6], [8, 10]]            #   then end as a tie-break

This is the sorting insight from Part 9 doing exactly what it promised: after sorting by start, an interval can only conflict with intervals at or after its own position, and the earliest-starting unprocessed conflict is the very next one in line.

The overlap condition — one comparison, one picture

Take two intervals, a = [1, 4] and b = [3, 6]. Do they overlap? Stare at the bars:

a [1,4]
b [3,6]

They share 3..4 — a's end (4) reaches past b's start (3). Overlap.

a's end reaches to or past b's start — that's the entire test. In symbols: a.end >= b.start, i.e. a[1] >= b[0]. If a's bar stops before b's bar begins, there's a gap and they're free:

a [1,3]
b [5,7]

a[1]=3 < b[0]=5 → no overlap: one ends entirely before the other begins.

After sorting by start (b begins at or after a), this single comparison is enough: next.start <= current.end means clash. Checking the reverse direction too (a.start ≤ b.end AND b.start ≤ a.end) is only needed for two unsorted intervals.

⚠️ The touching-endpoints trap — read the problem!

Do [1, 3] and [3, 5] overlap? It depends on the story:

  • Merging ranges → yes, treat as one block [1, 5] (they share the instant 3). Test: next.start <= current.end.
  • One person attending meetings → no clash: a meeting ending at 3 and another starting at 3 can both be attended. Test: next.start < current.end (strict).

The formulas differ by one character, the answers differ completely. When the statement is silent, state your assumption — and check the examples given with the problem; they always reveal the convention.


Merge Intervals — the cornerstone problem

20.1 Merge Intervals

🟡 L2 · Apply
Problem

Given a list of intervals, merge all overlapping ones and return the resulting non-overlapping list that covers exactly the same timeline.

Input / Output

[[1,3],[2,6],[8,10],[15,18]] → [[1,6],[8,10],[15,18]]

Key Observation

Sorted by start, overlaps are local: an interval can only extend (or start) the block currently being built — never some faraway block. So we can merge in one sweep, carrying a single "current merged block".

Brute Force

Repeatedly scan for any overlapping pair, merge it, and restart — until a full pass merges nothing. Correct, but each merge pass is O(n²) and you may need many passes. What repeated work disappears with sorting? The "scan everyone against everyone" — after sorting, each interval is compared with one block, not all others.

Optimal Idea

Sort by start. Keep the last merged block; for each next interval there are exactly two cases:

Case 1 — attaches
[1,3]
[2,6]

2 ≤ 3 → extend the block to [1, max(3,6)] = [1,6].

Case 2 — detached
[1,6]
[8,10]

8 > 6 → the block is finished; a new block begins.

Python Solution
Python
def merge_intervals(intervals):
    intervals.sort(key=lambda iv: iv[0])    # step 0: order by start
    merged = []
    for iv in intervals:
        # detached from everything so far? → start a new block
        if not merged or iv[0] > merged[-1][1]:
            merged.append(list(iv))
        else:
            # overlaps the current block → stretch it if needed
            merged[-1][1] = max(merged[-1][1], iv[1])
    return merged
Common Mistake — the max() is not optional

Consider [1, 10] followed by [2, 3]: overlapping, but the merged end must stay 10, not become 3. A contained interval stretches nothing. Always max(current_end, next_end).

Dry Run
Dry Run — [[1,3],[2,6],[8,10],[15,18]] (already sorted)
[1,3]  → merged empty            → new block: merged = [[1,3]]
[2,6]  → 2 ≤ 3 (attach)          → merged[-1] = [1, max(3,6)] = [1,6]
[8,10] → 8 > 6 (detached)        → merged = [[1,6],[8,10]]
[15,18]→ 15 > 10 (detached)      → merged = [[1,6],[8,10],[15,18]]
result: [[1,6],[8,10],[15,18]] ✅
Visual — before and after
in [1,3]
in [2,6]
in [8,10]
in [15,18]

input — the two overlapping bars (highlighted) will fuse.

out [1,6]
out [8,10]
out [15,18]

output — same total timeline coverage, now as clean, separated blocks.

Complexity

Time O(n log n) (the sort; the sweep is O(n)) · Space O(n) for the output.

Edge Cases
  • Empty input → [] (the not merged guard handles it).
  • An interval fully inside another, [[1,10],[2,3]] → survives as [1,10] thanks to max().
  • Touching intervals [[1,4],[4,5]] → merge to [1,5] under the standard convention.
Pattern Recognition
Pattern Insight

"Merge / combine / union of ranges" → sort by start, keep one growing block, extend with max, cut when a gap appears. If the statement hands you intervals and asks to consolidate them, this is the shape.

Insert Interval

20.2 Insert Interval

🟠 L3 · Recognize
Problem

You are given a list of non-overlapping intervals, already sorted by start, plus one new interval. Insert the new one and merge whatever it touches — in O(n), without re-sorting.

Input / Output

intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], new = [4,8] → [[1,2],[3,10],[12,16]]

Key Observation

Look at the timeline: the new bar [4,8] can only interact with its neighbourhood. To its left, intervals that end before 4 are untouched; to its right, intervals that start after 8 are untouched. Everything between them fuses into the new interval. That's a three-phase structure:

[1,2]
[3,5]
[6,7]
[8,10]
[12,16]
new [4,8]

[1,2] ends before 4 → untouched. [3,5], [6,7], [8,10] each start ≤ 8 → absorbed. [12,16] starts after 8 → untouched.

Brute Force

Append the new interval, re-sort everything, run merge_intervals — O(n log n), and it throws away the "already sorted" gift the problem gives you.

Optimal Idea

One left-to-right pass in three phases: (1) copy everything ending before new.start; (2) for every interval starting at or before new.end, merge it into new (stretch it both ways); (3) copy the rest. Phase 2's condition iv[0] <= new[1] is exactly the overlap test from earlier, applied while new grows.

Python Solution
Python
def insert_interval(intervals, new):
    result = []
    i, n = 0, len(intervals)

    # phase 1: safely before — end strictly before new starts
    while i < n and intervals[i][1] < new[0]:
        result.append(intervals[i])
        i += 1

    # phase 2: overlapping — start at or before new ends → absorb into new
    while i < n and intervals[i][0] <= new[1]:
        new[0] = min(new[0], intervals[i][0])
        new[1] = max(new[1], intervals[i][1])
        i += 1
    result.append(new)

    # phase 3: safely after — start after new ends
    while i < n:
        result.append(intervals[i])
        i += 1

    return result
Dry Run
Dry Run — [[1,2],[3,5],[6,7],[8,10],[12,16]], new = [4,8]
phase 1: [1,2] → end 2 < 4 → copy.  [3,5] → end 5 < 4? no → stop
phase 2: [3,5]  start 3 ≤ 8 → new = [min(4,3), max(8,5)] = [3,8]
         [6,7]  start 6 ≤ 8 → new = [3, max(8,7)]  = [3,8]
         [8,10] start 8 ≤ 8 → new = [3, max(8,10)] = [3,10]
         [12,16] start 12 ≤ 10? no → stop → push [3,10]
phase 3: copy [12,16]
result: [[1,2],[3,10],[12,16]] ✅
Visual — the result
out [1,2]
out [3,10]
out [12,16]

the new bar [4,8] swallowed three intervals and grew into [3,10].

Complexity

Time O(n) — one pass, no re-sorting (that's the point of the exercise) · Space O(n) for the result.

Edge Cases
  • New interval fits cleanly in a gap, e.g. new = [4,4] between [1,2] and [6,7] → phase 2 absorbs nothing, new lands between them.
  • Empty input → result is just [new].
  • New interval engulfs everything, e.g. new = [0, 100] → phase 2 absorbs the whole list.
Pattern Recognition
Pattern Insight

"Insert / add one item into an already-organised structure" → find the untouched left zone, process only the interacting middle, copy the untouched right zone. The overlap condition from the last section is the phase-2 condition — the pattern generalises rather than changes.

Meeting Rooms — overlap detection and counting

Meeting questions come in two flavours, both built on the overlap condition:

Flavour 1: if any two meetings overlap, one person can't attend everything. Sort by start, then check adjacent pairs — sorted order guarantees that any clash anywhere shows up as a clash between some meeting and the one right before it.

Python
def can_attend_all(meetings):
    meetings.sort(key=lambda m: m[0])
    for i in range(1, len(meetings)):
        # starts before the previous one ends → clash
        # (strict < : ending at 3 and starting at 3 is allowed)
        if meetings[i][0] < meetings[i - 1][1]:
            return False
    return True

# can_attend_all([[0,30],[5,10],[15,20]]) → False
# can_attend_all([[7,10],[2,4]])          → True  (after sorting: [2,4],[7,10])
Dry Run — [[0,30],[5,10],[15,20]]
sorted → [[0,30],[5,10],[15,20]]
i=1 → [5,10] starts at 5 < 30 (previous end) → CLASH → False ✅
[0,30]
[5,10]
[15,20]

[0,30] runs under everything — no single person can attend all three. Note [5,10] vs [15,20] alone would be fine.

Time O(n log n) · Space O(1).

Flavour 2 (count concurrent): how many meetings run at the same time at the worst moment? That number is the minimum number of rooms. Beginner method: keep two sorted lists — one of start times, one of end times — and walk through starts. Before each meeting starts, count how many earlier meetings have ended; if none has, a new room is consumed.

Python
def min_rooms(meetings):
    starts = sorted(m[0] for m in meetings)
    ends   = sorted(m[1] for m in meetings)

    rooms = 0
    j = 0                       # ends[j] = earliest end still "in the future"
    for s in starts:
        if s < ends[j]:         # nothing free yet → need one more room
            rooms += 1
        else:                   # a meeting ended at exactly/before s → reuse it
            j += 1
    return rooms

# min_rooms([[0,30],[5,10],[15,20]]) → 2
Dry Run — [[0,30],[5,10],[15,20]] → starts [0,5,15], ends [10,20,30]
s=0  → 0 < 10  → no room free → rooms=1
s=5  → 5 < 10  → no room free → rooms=2
s=15 → 15 ≥ 10 → meeting ending at 10 freed a room → j=1 (rooms stay 2)
answer: 2 ✅  ([0,30] and [5,10] overlap → 2 rooms; [15,20] reuses the freed one)
room 1
room 2
room 2

two rooms suffice: [0,30] in room 1; [5,10] then [15,20] share room 2.

Tip — why comparing starts against ends works

At the moment meeting s begins, the rooms it can use are exactly the meetings that have already ended. The smallest available end-time is ends[j] — if it's still in the future (s < ends[j]), every room is busy. Note rooms never decreases: it's already tracking the historical maximum, which is precisely the answer.

Time O(n log n) for the two sorts · Space O(n) for the copies.

Non-Overlapping Intervals — greedy removal

20.3 Erase Minimum Intervals to Remove All Overlaps

🟠 L3 · Recognize
Problem

Given intervals, remove as few as possible so that the survivors are pairwise non-overlapping (touching endpoints are allowed). Return the removal count.

Input / Output

[[1,2],[2,3],[3,4],[1,3]] → 1 (drop [1,3]; the rest chain cleanly)

Key Observation

This is a greedy problem wearing interval clothes. Which interval should we keep? The one that ends earliest — it vacates the timeline soonest and interferes with the fewest later intervals. So: sort by end, then walk once, keeping every interval that starts at or after the end of the last kept one, and counting the rest for removal.

Why sort by end (and not by start)?

Compare [1, 100] and [2, 3]. Sorted by start, the greedy might keep the 100-wide monster and be forced to drop [2,3] plus everything it overlapped. Sorted by end, [2,3] is considered first, kept, and the monster becomes the one that clashes. Earliest end = least blocking = the safe greedy pick. (This is the same "choose what frees resources soonest" instinct that drives the sorting + greedy pairing.)

Python Solution
Python
def min_removals(intervals):
    intervals.sort(key=lambda iv: iv[1])   # sort by END
    removed = 0
    last_end = None                        # end of the last kept interval
    for iv in intervals:
        if last_end is None or iv[0] >= last_end:
            last_end = iv[1]               # no clash → keep it
        else:
            removed += 1                   # clashes with a kept one → drop it
    return removed
Tip — keep vs remove, same coin

Counting removals and counting keeps are equivalent (removals = n - keeps). This greedy is usually taught as "maximum number of non-overlapping intervals you can keep" — one loop, two problems. Whichever the statement asks, run the same scan and report the other side.

Dry Run
Dry Run — [[1,2],[2,3],[3,4],[1,3]] sorted by end → [1,2],[2,3],[1,3],[3,4]
[1,2] → last_end None → KEEP, last_end=2
[2,3] → 2 ≥ 2 → KEEP, last_end=3          (touching is fine)
[1,3] → 1 < 3 → REMOVE, removed=1
[3,4] → 3 ≥ 3 → KEEP, last_end=4
answer: 1 ✅
Visual
keep [1,2]
keep [2,3]
cut [1,3]
keep [3,4]

[1,3] (highlighted) is the one interval worth sacrificing — it clashes with two keepers while adding no unique time.

Complexity

Time O(n log n) (sort) · Space O(1).

Edge Cases
  • Single interval → 0 removals.
  • All identical intervals [[1,2],[1,2],[1,2]] → keep 1, remove 2 (later copies start 1 < last_end 2).
  • Already non-overlapping → 0 removals, the scan just keeps everything.
Common Mistakes
  • Sorting by start instead of end — the greedy breaks (see the [1,100] example above).
  • Using > instead of >= — under the touching-allowed convention, [2,3] after [1,2] must be kept.
  • Trying to be clever with removal pairs: once sorted by end, one pass is provably optimal — no second-guessing needed.
Pattern Recognition
Pattern Insight

"Minimum removals / maximum compatible picks" over intervals → sort by end and greedily keep whatever fits. The keyword is compatibility: intervals that don't clash form a chain, and the earliest-ending candidate is always the safest chain extension.

Pattern Insight — the interval family recipe

"Intervals in the statement → sort by start (or end), then scan." Which sort to pick:

  • Merge / union / total coverage → sort by start, grow one block.
  • Clash detection / rooms for meetings → sort by start and compare with the previous end (or starts-vs-ends sweep).
  • Keep as many / remove as few as possible → sort by end, greedy chain.

All three are one linear pass over sorted data. If you ever find yourself comparing every pair of intervals, stop — you skipped the sort.

What You Learned

  • Intervals are [start, end] pairs; drawn as bars on a shared timeline they turn abstract comparisons into pictures.
  • Sorting by start makes every conflict local — neighbours are the only candidates to clash.
  • Overlap after sorting = next.start ≤ current.end (merge convention) — and whether touching endpoints count is a per-problem convention you must check.
  • Merge intervals: one growing block, extend with max(end), cut on gaps — O(n log n).
  • Insert interval: untouched left / absorbing middle / untouched right, in one O(n) pass.
  • Meeting rooms: clash detection via adjacent pairs; room counting via sorted starts vs sorted ends.
  • Maximum keeps / minimum removals: sort by end, greedily extend the compatible chain.

Pattern Recognition

The trigger is embarrassingly direct: whenever the input contains ranges with a start and an end — meetings, bookings, watch sessions, time slots — think "sort, then sweep". Choose the sort key by the question: start for merging and clash detection, end for keep-the-most greedy. Then the answer almost always falls out of one linear pass, exactly as Part 9 promised: sorting makes the structure visible, and visible structure scans in O(n).

Complexity Summary

ProblemSort keyTimeSpace
Merge intervalsstartO(n log n)O(n) output
Insert interval (input pre-sorted)— (already sorted)O(n)O(n) output
Can attend all meetingsstartO(n log n)O(1)
Minimum rooms (count concurrent)starts + ends separatelyO(n log n)O(n)
Min removals / max keepsendO(n log n)O(1)

Common Mistakes

  • Forgetting max() when extending a merged block — a fully contained interval like [2,3] inside [1,10] must not shrink the end.
  • Using the wrong endpoint convention: merging wants <=, single-person attendance wants strict <. Always confirm from the problem's examples.
  • Sorting by start for the removal problem — the greedy needs the earliest end.
  • Comparing every pair of intervals (O(n²)) because the sort step was skipped.
  • In insert-interval, re-sorting and discarding the O(n) opportunity the pre-sorted input hands you.

Mini Quiz

Q1. After sorting by start, when does [a] overlap the next interval [b] (merge convention)?
With b starting at or after a, the only way to collide is b starting before (or exactly when) a ends: b[0] ≤ a[1].
Q2. In the minimum-removals greedy, why sort by end?
Keeping the interval that frees the timeline soonest is never worse — it leaves maximum room for everything that follows.
Q3. Can one person attend [1,3] and [3,5]?
Attendance uses the strict test next.start < prev.end: 3 < 3 is false → no clash. (For merging, these two WOULD fuse — conventions differ!)
Q4. Minimum meeting rooms via the starts/ends sweep: what do you compare?
A start needs a room only if it's earlier than the earliest end not yet processed — s < ends[j] → new room, else reuse.

Practice Problems

P1 — Does Any Pair Conflict?

🟢 L1

Return True if any two intervals overlap (touching allowed, meeting convention). [[1,4],[3,5]] → True; [[1,2],[2,3]] → False.

Show Hint

Sort by start — then a conflict, if it exists anywhere, appears between neighbours.

Show Approach

Adjacent scan with the strict test cur.start < prev.end.

Show Solution
Python
def has_conflict(intervals):
    intervals.sort(key=lambda iv: iv[0])
    for i in range(1, len(intervals)):
        if intervals[i][0] < intervals[i - 1][1]:
            return True
    return False

# has_conflict([[1,4],[3,5]]) → True
# has_conflict([[1,2],[2,3]]) → False

P2 — Total Covered Time

🟡 L2

Return the total length of timeline covered (count overlaps once). [[1,4],[2,6]] → 5 (1..6); [[1,4],[6,8]] → 5 (3 + 2).

Show Hint

Overlapping coverage is one merged block — and you already own a function that merges.

Show Approach

Merge intervals, then sum end - start over the merged blocks. Overlaps are counted once by construction.

Show Solution
Python
def covered_length(intervals):
    merged = merge_intervals(intervals)     # reuse 20.1
    total = 0
    for s, e in merged:
        total += e - s
    return total

# covered_length([[1,4],[2,6]]) → 5
# covered_length([[1,4],[6,8]]) → 5

P3 — Find the Free Slots

🟡 L2

Given sorted, non-overlapping busy intervals, return the gaps between them. [[1,3],[5,7],[9,10]] → [[3,5],[7,9]].

Show Hint

A gap lives between one interval's end and the next interval's start. When is it non-empty?

Show Approach

Scan adjacent pairs; if next.start > cur.end, record [cur.end, next.start]. (Note the strict >: touching intervals leave no gap.)

Show Solution
Python
def free_slots(busy):
    gaps = []
    for i in range(1, len(busy)):
        prev_end = busy[i - 1][1]
        cur_start = busy[i][0]
        if cur_start > prev_end:
            gaps.append([prev_end, cur_start])
    return gaps

# free_slots([[1,3],[5,7],[9,10]]) → [[3,5],[7,9]]
# free_slots([[1,3],[3,5]])        → []  (touching → no gap)

Challenge Problem

Challenge — Minimum Number of Platforms

🟠 L3 · Recognize

Trains arrive and depart: trains = [[9,11],[10,12],[11,13]] (arrival, departure). A platform is free the instant a train departs — a train arriving at exactly that moment may reuse it. How many platforms does the station need? (Expect 2.)

Show Hint

"How many trains are at the station at the same time?" is the meeting-rooms question with different words.

Show Approach

Sort arrivals and departures separately; walk the arrivals, reusing platforms whose train has departed (arrival ≥ earliest pending departure).

Show Solution
Python
def min_platforms(trains):
    arrivals   = sorted(t[0] for t in trains)
    departures = sorted(t[1] for t in trains)

    platforms = 0
    j = 0
    for a in arrivals:
        if a < departures[j]:
            platforms += 1        # earliest train still there → new platform
        else:
            j += 1                # it departed at exactly/before a → reuse
    return platforms

# min_platforms([[9,11],[10,12],[11,13]]) → 2
# trace: arrivals [9,10,11], departures [11,12,13]
#        9 < 11 → 1 · 10 < 11 → 2 · 11 ≥ 11 → reuse → 2 ✅

Spot the transfer: the statement said "trains and platforms", not "intervals" — but arrival/departure pairs are intervals, so the starts-vs-ends sweep applies untouched. That translation step is the real skill this part trains.

Revision Checklist

PART 21

Bit Manipulation Basics

Computers store every number as bits. A handful of bit tricks turn some slow problems into one-pass, zero-extra-space solutions.

Every integer inside your computer is a row of 0s and 1s. Most of the time Python hides that from you — but a few problems become dramatically simpler once you look at the bits directly. Classic examples you will meet soon: finding the one unpaired element in a list, and checking whether a number is a power of two without any loops. Both need exactly two operators and one insight each.

This part covers only the basics: reading binary, the operators &, |, ^, ~, and the two shifts. That toolkit is enough for a surprising number of interview problems.

How to read a binary number

Decimal (base 10) gives each digit a place value that is a power of 10. Binary (base 2) does the same with powers of 2. An 8-bit number 11001010 reads like this:

Bit (left to right)11001010
Place value1286432168421
Contributes12864008020

Sum the contributions: 128 + 64 + 8 + 2 = 202. So binary 11001010 is decimal 202. A bit is "set" when it is 1. The rightmost bit (place value 1) is called the least significant bit, or last bit — it alone tells you whether the number is odd.

Python converts for you with bin():

Python
print(bin(5))        # 0b101        (5 = 4 + 1)
print(bin(13))       # 0b1101       (13 = 8 + 4 + 1)
print(bin(202))      # 0b11001010

# the 0b prefix just means "this is binary":
print(int("11001010", 2))    # 202  (read binary back to decimal)

# fixed-width 8-bit string, zero-padded (handy for tables):
print(format(13, "08b"))     # 00001101
Q1. What is binary 1011 in decimal?
8 + 0 + 2 + 1 = 11. Each 1-bit adds its place value.

The operators — AND, OR, XOR, NOT

All four operators work bit by bit, independently. They compare each column of the two numbers and produce a bit for that column.

AND — a & b: 1 only when both are 1

Think of it as a strict filter: a column survives only if both numbers have a 1 there.

ExpressionIn binaryResult
12 & 1000001100 & 0000101000001000 = 8
9 & 600001001 & 0000011000000000 = 0
7 & 100000111 & 0000000100000001 = 1 (odd)
10 & 100001010 & 0000000100000000 = 0 (even)

The last two rows are the classic odd/even test: n & 1 keeps only the last bit, and the last bit of an odd number is always 1.

OR — a | b: 1 when at least one is 1

ExpressionIn binaryResult
12 | 1000001100 | 0000101000001110 = 14
9 | 600001001 | 0000011000001111 = 15

OR is how you turn a bit on without touching the others: n | (1 << k) sets bit k.

XOR — a ^ b: 1 when the bits are different

XOR (exclusive or) answers "do these two bits disagree?" It has three properties that power almost every bit-manipulation problem:

Concept — the three XOR rules
  • x ^ x = 0 — a number cancels itself out.
  • x ^ 0 = x — zero is the "do nothing" value.
  • XOR is order-independent: a ^ b ^ c gives the same answer in any order.

Together: if values pair up, every pair XORs to 0, and only the unpaired value survives. That is the whole trick behind the Single Number problem below.

ExpressionIn binaryResult
12 ^ 1000001100 ^ 0000101000000110 = 6
9 ^ 600001001 ^ 0000011000001111 = 15
5 ^ 500000101 ^ 0000010100000000 = 0

NOT — ~a: flips every bit

Flipping every bit of a positive number gives a huge number of leading 1s, so Python represents the result as a negative number. The usable rule is simple:

Python
print(~5)     # -6   (rule: ~a == -(a + 1))
print(~12)    # -13
print(~0)     # -1
Tip — where NOT is actually used

You rarely use ~ on its own. Its one everyday job is clearing a bit: n & ~(1 << k) switches bit k off, because ~(1 << k) is "all ones except position k". You will see it in the set/clear/toggle card below.

Left shift and right shift

Shifting moves all bits left or right by k positions.

Left shift — a << k

Each position doubles the value. a << k = a * 2^k.

ExpressionMeaningResult
3 << 13 * 26
5 << 25 * 420
1 << 32^38
Right shift — a >> k

Each position halves the value (rounded down). a >> k = a // 2^k.

ExpressionMeaningResult
12 >> 112 // 26
13 >> 213 // 43 (13.25 rounds down)
1 >> 11 // 20

Two habits worth building now:

  • 1 << k is how you build the number that has only bit k set — the "mask" you combine with &, |, ^.
  • Python integers have no fixed width, so 1 << 100 is a perfectly legal (huge) number — no overflow, no wrapping.
Q2. What is the value of 6 ^ 6?
Rule 1 of XOR: x ^ x = 0. Identical bits never disagree.

Bitwise ops cheat table

One screen, one example pair: a = 12 (00001100) and b = 10 (00001010).

OperationPythonResult with a=12, b=10Use it for
ANDa & b8 (00001000)keep bits present in both; test a single bit
ORa | b14 (00001110)turn bits on
XORa ^ b6 (00000110)flip bits; cancel pairs
NOT~a-13 (rule: -(a+1))build "all ones except k" to clear a bit
Left shifta << 124 (= a * 2)multiply by 2^k; build masks 1 << k
Right shifta >> 23 (= a // 4)divide by 2^k; drop low bits

Five identities that solve most bit problems

IdentityWhat it tells youExample
n & 1the last bit → odd (1) or even (0)7 & 1 = 1, 10 & 1 = 0
n & (n - 1)n with its lowest set bit removed12 & 11 = 8
x ^ x = 0equal values cancel9 ^ 9 = 0
x ^ 0 = xzero is neutral9 ^ 0 = 9
(n >> k) & 1the value of bit k(13 >> 2) & 1 = 1

Check the second row by hand: 12 = 1100, 11 = 1011, AND → 1000 = 8. The shared leading bits survive; the lowest set bit does not. This one line is the engine of the power-of-two test coming up.

Q3. Using the identities table: what is (10 >> 1) & 1 — the value of bit 1 of the number 10?
10 = 1010 in binary. 10 >> 1 = 5 (101), and 5 & 1 = 1. Bit 1 of 10 is indeed set.

Problem 21.1 — Single Number (every value appears twice, one appears once)

21.1 Single Number

🟡 L2 · Apply
Problem

Given a non-empty list of integers where every element appears exactly twice except one element that appears once, find that single element.

Input / Output

nums = [4, 1, 2, 1, 2] → 4  ·  nums = [7, 3, 7] → 3

Key Observation

Counting with a dictionary works, but it costs O(n) extra space and it does not use the crucial hint: values come in pairs. Ask instead: what operation makes a pair vanish? XOR does — x ^ x = 0 and x ^ 0 = x.

Brute Force

Count occurrences with a dictionary, then return the key with count 1. Correct, O(n) time, but O(n) extra space — and it treats "every value appears twice" as irrelevant information.

The XOR insight, slowly

Take [4, 1, 2, 1, 2]. XOR is order-independent, so you may imagine reordering and grouping the numbers by value first:

Why pairs cancel — regroup the XOR
4 ^ 1 ^ 2 ^ 1 ^ 2
= (1 ^ 1) ^ (2 ^ 2) ^ 4     ← group the equal pairs together
=    0    ^    0    ^ 4     ← rule: x ^ x = 0
= 4                          ← rule: 0 ^ 4 = 4

Every value that appears twice contributes 0. Only the unpaired value survives — no matter what order the list is in. So one accumulator, XOR-ing everything, must end holding the answer.

Python Solution
Python
def single_number(nums):
    acc = 0
    for x in nums:
        acc = acc ^ x      # pairs cancel; the single value survives
    return acc

print(single_number([4, 1, 2, 1, 2]))   # 4
print(single_number([7, 3, 7]))         # 3
Dry Run — nums = [7, 3, 7]
Three steps, then done
Step 1: x = 7 → acc = 0 ^ 7 = 7
Step 2: x = 3 → acc = 7 ^ 3 = 4      (0111 ^ 0011 = 0100)
Step 3: x = 7 → acc = 4 ^ 7 = 3      (0100 ^ 0111 = 0011)
Loop ends → return 3 ✅

Notice the intermediate value (4) looks meaningless — that is normal. XOR does not care; only the final state does.

Complexity

Time O(n) — one pass · Space O(1) — one integer accumulator

Edge Cases
  • List of length 1: [9] → accumulator ends at 0 ^ 9 = 9. Works.
  • The single value appears first, last, or in the middle — order never matters (XOR rule 3).
  • Negative numbers work too: XOR on the underlying bits behaves the same.
  • If the guarantee "exactly one unpaired value" is broken (e.g. three unpaired values), the result is garbage — the trick depends on the promise.
Common Mistakes
  • Using + instead of ^: pairs would add up, not cancel.
  • Trying to sort first "to make pairs adjacent" — unnecessary and slower (O(n log n)).
  • Assuming the accumulator must stay small or meaningful during the run.
Pattern Recognition
Pattern Insight

"Every element appears k times except one" + you only need the odd one out → think XOR cancellation. If the statement had said "exactly twice except one", the answer is a one-liner accumulator. (A variant with "three times except one" needs counting, not plain XOR — see the challenge for a related XOR problem that does fit.)

Problem 21.2 — Check if a number is a power of two

21.2 Power of Two

🟢 L1 · Understand
Problem

Given a positive integer n, decide whether it is an exact power of two: 1, 2, 4, 8, 16, 32, ... (i.e. n == 2^k for some k >= 0).

Input / Output

n = 16 → True  ·  n = 18 → False  ·  n = 1 → True (2^0)

Key Observation

Look at powers of two in binary — each has exactly one set bit:

Python
1  = 0001        4  = 0100        16 = 10000
2  = 0010        8  = 1000        32 = 100000

Now subtract 1 from such a number. Borrowing turns the single 1 into 0 and everything below it into 1:

Python
16 - 1  =  15  =  01111
 8 - 1  =   7  =  00111

The two patterns are perfect opposites — a 1 in one is a 0 in the other. So n & (n - 1) is 0 exactly when n had a single set bit. For a non-power like 18 = 10010, subtracting gives 10001, and the top bits overlap: 10010 & 10001 = 10000 = 16, which is not 0.

Optimal Idea

n > 0 and (n & (n - 1)) == 0. One line, no loops. The n > 0 guard is essential: 0 & -1 == 0, so without the guard, 0 would wrongly pass.

Python Solution
Python
def is_power_of_two(n):
    return n > 0 and (n & (n - 1)) == 0

for n in (1, 2, 16, 18, 0, -8):
    print(n, is_power_of_two(n))
# 1 True, 2 True, 16 True, 18 False, 0 False, -8 False
Dry Run — n = 16 and n = 18
Two numbers through the same test
n = 16:  n > 0 ✅
         16      = 10000
         16 - 1  = 01111
         AND     = 00000  → == 0 → True ✅

n = 18:  n > 0 ✅
         18      = 10010
         18 - 1  = 10001
         AND     = 10000  → != 0 → False ✅
Complexity

Time O(1) · Space O(1)

A beginner fallback (also correct)

If you forget the trick in an interview, a loop is fine and shows the same understanding: divide by 2 while even, then check whether you landed on 1.

Python
def is_power_of_two_loop(n):
    if n <= 0:
        return False
    while n % 2 == 0:
        n //= 2
    return n == 1

That is O(log n). The bit test is O(1) — same answer, no loop.

Pattern Recognition
Pattern Insight

Whenever a statement is about powers of two, multiples of two, or "the largest power of 2 <= n", write the numbers in binary first. Powers of two have one set bit; n & (n-1) deletes the lowest set bit — and between them, most such problems collapse.

Full-but-compact: three more tools for your kit

21.3 Odd or even with &

🟢 L1

(n & 1) == 0 → even, else odd. Faster to type than n % 2 == 0 and works for negatives in Python (-3 & 1 == 1 → odd ✅). The last binary digit is the parity.

Show Solution
Python
def parity(n):
    return "odd" if n & 1 else "even"

print(parity(7))    # odd
print(parity(10))   # even
print(parity(-3))   # odd

21.4 Count set bits (basic loop)

🟢 L1

Count the 1s in the binary form of n. Chop bits off from the right: read the last bit with n & 1, remove it with n >>= 1, repeat until n is 0. (Neat alternative, same idea as 21.2: count += 1; n &= n - 1 removes one set bit per step.)

Show Solution
Python
def count_set_bits(n):
    count = 0
    while n:
        count += n & 1   # last bit
        n >>= 1          # drop it
    return count

print(count_set_bits(13))   # 3  (1101)
print(count_set_bits(16))   # 1  (10000)
Dry Run — n = 13 (1101)
Step 1: 13 & 1 = 1 → count = 1, n = 6  (110)
Step 2:  6 & 1 = 0 → count = 1, n = 3  (11)
Step 3:  3 & 1 = 1 → count = 2, n = 1  (1)
Step 4:  1 & 1 = 1 → count = 3, n = 0  → stop → 3 ✅

21.5 Set, clear, toggle, check — one bit at position k

🟡 L2

Four operations, four one-liners (positions count from 0 at the rightmost bit). The mask 1 << k is a 1 with k zeros — it isolates position k:

GoalCodeExample: n = 13 (1101)
set bit kn | (1 << k)set bit 1 → 1101 | 0010 = 1111 = 15
clear bit kn & ~(1 << k)clear bit 2 → 1101 & 1011 = 1001 = 9
toggle bit kn ^ (1 << k)toggle bit 1 → 1101 ^ 0010 = 1111 = 15
check bit k(n >> k) & 1check bit 2 → 13 >> 2 = 3, 3 & 1 = 1
Show Solution
Python
def set_bit(n, k):
    return n | (1 << k)

def clear_bit(n, k):
    return n & ~(1 << k)

def toggle_bit(n, k):
    return n ^ (1 << k)

def check_bit(n, k):
    return (n >> k) & 1

n = 13                     # 1101
print(set_bit(n, 1))       # 15
print(clear_bit(n, 2))     # 9
print(toggle_bit(n, 1))    # 15
print(check_bit(n, 2))     # 1
Q4. In single_number, why does the accumulator end at the answer?
Order does not matter (rule 3), so mentally group the pairs: every pair → 0, and only 0 ^ single remains.
Q5. Why does is_power_of_two need the n > 0 guard?
0 has no set bits, so the bit test alone says "power of two". The guard rejects 0 (and negatives) first.
Important — when NOT to use bits

Bit tricks are a bonus layer, not a default. If a plain dictionary or modulo does the job clearly, prefer it. Reach for bits when the statement hints at them: pairs cancelling, powers of two, parity, or "in-place with O(1) extra space".

What You Learned

  • Read binary numbers via place values; convert with bin() and format(n, "08b").
  • & (both 1), | (any 1), ^ (different), ~ (flip, -(a+1)), and the shifts <</>> (times / divided by 2^k).
  • XOR's three rules: x ^ x = 0, x ^ 0 = x, order does not matter.
  • n & (n-1) removes the lowest set bit — zero only for powers of two.
  • Per-bit work with masks: 1 << k builds a mask; (n >> k) & 1 reads bit k.

Pattern Recognition

Reach for bits when the problem mentions: exactly one unpaired value (XOR), powers/multiples of two (single set bit, & (n-1)), parity of a number (n & 1), or O(1) extra space on a numeric problem. Write 2-3 examples in binary on paper first — the structure usually announces itself.

Complexity Summary

Problem / operationTimeSpace
Single number (XOR pass)O(n)O(1)
Power of two (n & (n-1))O(1)O(1)
Count set bits (loop)O(number of bits)O(1)
Set / clear / toggle / check one bitO(1)O(1)
Odd/even via n & 1O(1)O(1)

Common Mistakes

  • Forgetting the n > 0 guard in the power-of-two test (0 passes the bit check).
  • Confusing & with and — one is bitwise, the other is boolean logic.
  • Forgetting operator precedence: Python binds & looser than ==, so write (n & (n-1)) == 0 with parentheses.
  • Assuming >> on a negative number behaves like division — avoid shifting negatives; test positives first.
  • Using XOR cancellation when the guarantee is "appears 3 times" — pairs no longer cancel to 0.

Mini Quiz

Q1. Binary 1101 is which decimal number?
8 + 4 + 0 + 1 = 13.
Q2. (n & 1) == 0 is True when n is...
The last bit of an even number is 0, so AND-ing with 1 yields 0.
Q3. What is 5 << 3?
5 * 2^3 = 5 * 8 = 40.
Q4. Time and space of the XOR solution to Single Number?
One pass over the list with a single accumulator — no dictionary needed.

Practice Problems

Practice 1 — Binary string without bin()

🟢 L1

Print the binary form of a positive integer by extracting bits with % 2 and // 2 (no bin()).

Show Hint

The last bit is n % 2; collect bits in a list, then reverse.

Show Approach

Loop while n > 0: append str(n % 2), then n //= 2. Reverse and join at the end — bits come out least-significant first.

Show Solution
Python
def to_binary(n):
    if n == 0:
        return "0"
    bits = []
    while n:
        bits.append(str(n % 2))
        n //= 2
    return "".join(reversed(bits))

print(to_binary(13))   # 1101
print(to_binary(4))    # 100

Practice 2 — Count powers of two in a list

🟡 L2

Given a list of positive integers, count how many are exact powers of two. [1, 3, 4, 7, 16, 18] → 3.

Show Hint

You already own an O(1) test for "power of two" — Problem 21.2.

Show Approach

One pass; for each x apply x > 0 and (x & (x-1)) == 0 and count hits.

Show Solution
Python
def count_powers_of_two(nums):
    count = 0
    for x in nums:
        if x > 0 and (x & (x - 1)) == 0:
            count += 1
    return count

print(count_powers_of_two([1, 3, 4, 7, 16, 18]))   # 3

Practice 3 — XOR swap

🟡 L2

Swap two integer variables using only XOR — no temp variable, no tuple assignment.

Show Hint

Three XOR assignments. Trace which value each variable holds after each line.

Show Approach

a ^= b makes a hold a^b. Then b ^= a turns b into b ^ (a^b) = a. Then a ^= b turns a into (a^b) ^ a = b.

Show Solution
Python
a, b = 3, 9
a ^= b
b ^= a        # b is now the old a
a ^= b        # a is now the old b
print(a, b)   # 9 3

A fun trick, but in real Python code prefer a, b = b, a — clearer and just as fast.

Challenge Problem

Challenge — Missing Number with XOR (no sum formula)

🟠 L3 · Recognize

The list contains the numbers 0..n with exactly one missing. Find the missing number using XOR only — no sum(), no sorting. [3, 0, 1] → 2.

Show Hint

XOR together every index 0..n AND every list value. Pairs cancel.

Show Solution

XOR the whole range 0..n first, then XOR every list value. Every number that is actually present now appears twice in the combined stream (once from the range, once from the list) and cancels itself. The missing number appears only once — in the range — so it survives.

Python
def missing_number(nums):
    n = len(nums)          # values are 0..n, so n = len
    acc = 0
    for i in range(n + 1): # 0..n
        acc ^= i
    for x in nums:         # present values cancel their twins
        acc ^= x
    return acc

print(missing_number([3, 0, 1]))    # 2
print(missing_number([0, 1]))       # 2
Dry Run — nums = [3, 0, 1]
Phase 1 — XOR the full range 0..n (n = len = 3):
  acc = 0
  i = 1:  acc = 0 ^ 1 = 1
  i = 2:  acc = 1 ^ 2 = 3
  i = 3:  acc = 3 ^ 3 = 0        ← 0^1^2^3 = 0 (complete set cancels)

Phase 2 — XOR the list values (each present value cancels its twin):
  x = 3:  acc = 0 ^ 3 = 3
  x = 0:  acc = 3 ^ 0 = 3
  x = 1:  acc = 3 ^ 1 = 2        ← only the missing number survives

return 2 ✅

Time O(n), space O(1) — same shape as the sum-formula solution, but it cannot overflow in languages with fixed-width integers (in Python that is a non-issue, yet the pattern is worth knowing).

Revision Checklist

PART 22

Basic Math for DSA

GCD, primes, factors, digits and modulo — the small toolkit behind a huge number of beginner problems.

A surprising share of easy-to-medium problems are really math problems in disguise: schedule overlaps reduce to GCD, "count primes up to n" needs a sieve, "reverse an integer" is a digit loop, and half of all judges want answers modulo 10^9 + 7. None of this needs school-level theory — just six small tools, each explained with a dry run you can replicate on paper.

GCD — the Euclidean algorithm

GCD (greatest common divisor) of two numbers is the largest integer that divides both. gcd(48, 18) = 6: the divisors of 18 are 1, 2, 3, 6, 9, 18, and the largest of those that also divides 48 is 6. ✅

The ancient trick: replace the pair (a, b) with (b, a mod b) until the second number hits 0. The surviving number is the GCD.

Dry Run — gcd(48, 18)
Step 1: (48, 18) → 48 % 18 = 12 → new pair (18, 12)
Step 2: (18, 12) → 18 % 12 =  6 → new pair (12,  6)
Step 3: (12,  6) → 12 %  6 =  0 → new pair ( 6,  0)
Second value is 0 → answer is the remaining value: 6 ✅
Concept — why this works (the short version)

Write a = q·b + r (that is what a % b = r means). Any number that divides both a and b must also divide r = a - q·b. So the pair (a, b) and the pair (b, r) have exactly the same common divisors — including the greatest one. We keep shrinking the numbers (r is always smaller than b) without ever changing the answer, so we must eventually reach r = 0, where the answer is sitting in plain sight.

Python
def gcd(a, b):
    while b:
        a, b = b, a % b
    return a

print(gcd(48, 18))     # 6
print(gcd(17, 5))      # 1  (no common divisor except 1)

Python also ships this: from math import gcd. Know how to write it yourself — it is a 3-line interview favourite.

Drill — Euclid by hand

🟢 L1

Trace gcd(1071, 462) on paper, then check with code.

Show Solution
Dry Run — gcd(1071, 462)
Step 1: (1071, 462) → 1071 % 462 = 147 → (462, 147)
Step 2: (462, 147)  → 462 % 147 = 21   → (147, 21)
Step 3: (147, 21)   → 147 % 21 = 0     → (21, 0)
→ gcd = 21 ✅
Q1. What does gcd(12, 18) return?
(12, 18) → (18 % 12 = 6) → (12, 6) → (6, 0) → 6.

LCM — built from GCD

LCM (least common multiple) is the smallest number both inputs divide. The one-liner relationship:

Concept — lcm(a, b) = a · b / gcd(a, b)

Split a and b into their shared part (the GCD) and their private parts. Multiplying them counts the shared part twice; dividing by the GCD once leaves exactly the smallest common multiple. Example: lcm(4, 6): gcd = 2, so 4 · 6 / 2 = 12 — and indeed 12 is the first number both 4 and 6 divide.

Python
def lcm(a, b):
    return a // gcd(a, b) * b   # divide first: keeps the middle value small

print(lcm(4, 6))       # 12
print(lcm(12, 18))     # 36

Order matters in fixed-width languages: a * b can overflow before the division. Python is safe either way, but a // gcd * b is the professional habit.

Drill — LCM in one line

🟢 L1

Compute lcm(12, 18) using the formula (no code needed).

Show Solution

gcd(12, 18) = 6, so lcm = 12 · 18 / 6 = 216 / 6 = 36. Check: 36 / 12 = 3 ✅ and 36 / 18 = 2 ✅.

Prime check — trial division up to sqrt(n)

A prime has no divisors except 1 and itself. The obvious test loops from 2 to n-1 — correct but wasteful. The key upgrade: only test divisors up to sqrt(n).

Concept — why sqrt(n) is enough

Divisors come in pairs: if a · b = n, the smaller partner is at most sqrt(n) (if both were bigger than sqrt(n), their product would exceed n). Example, n = 36: the pairs are (2, 18), (3, 12), (4, 9), (6, 6) — every pair contains a number <= 6 = sqrt(36). So if nothing <= sqrt(n) divides n, nothing at all does.

Python
def is_prime(n):
    if n < 2:
        return False        # 0, 1 and negatives are not prime
    if n == 2:
        return True
    if n % 2 == 0:
        return False        # only even prime is 2
    i = 3
    while i * i <= n:       # same as: i <= sqrt(n), but exact
        if n % i == 0:
            return False
        i += 2              # odd divisors only
    return True

print(is_prime(29))    # True
print(is_prime(49))    # False (7 * 7)
print(is_prime(1))     # False

i * i <= n is better than computing sqrt(n): it never touches floating point, so it stays exact for huge n.

Dry Run — is_prime(29)
n >= 2 ✅, n != 2, n odd → enter loop
i = 3:  3 * 3 = 9  <= 29 → 29 % 3 = 2 → keep going
i = 5:  5 * 5 = 25 <= 29 → 29 % 5 = 4 → keep going
i = 7:  7 * 7 = 49 > 29  → loop ends
No divisor found → True ✅   (tested only 3 and 5, not 2..28)

Drill — catch the composite

🟢 L1

Which trial divisions does is_prime(91) perform, and what does it return?

Show Solution

91 is odd, so i runs 3, 5, 7, ... while i * i <= 91 (sqrt(91) is about 9.5): i = 3 → 91 % 3 = 1; i = 5 → 91 % 5 = 1; i = 7 → 91 % 7 = 0 → return False (91 = 7 · 13). Three checks, done.

Sieve of Eratosthenes — all primes up to n

Checking numbers one by one is fine for a single query. For every prime up to n, the sieve is faster: cross out multiples of each prime, and whatever survives is prime.

1

List 2..n, assume everyone is prime. Take the smallest unmarked number: 2. Cross out 4, 6, 8, 10, ... (multiples of 2).

2

Next survivor: 3. Cross out 9, 15, 21, 27 (6, 12, 18 are already gone).

3

Next survivor: 5. Only 25 is new (10, 15, 20 already crossed).

4

Next survivor: 7 — but 7 · 7 = 49 > 30, so we can stop. Rule: once p · p > n, all remaining survivors are prime.

Numbers 2..30 after sieving — struck values are composite
234567891011
12131415161718192021
222324252627282930

Survivors: 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 — the 10 primes up to 30.

Python
def sieve(n):
    is_prime = [True] * (n + 1)
    is_prime[0] = is_prime[1] = False
    p = 2
    while p * p <= n:                 # stop at sqrt(n)
        if is_prime[p]:
            for multiple in range(p * p, n + 1, p):
                is_prime[multiple] = False
        p += 1
    return [i for i in range(2, n + 1) if is_prime[i]]

print(sieve(30))
# [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
Tip — two speed details in the code
  • Multiples start at p * p: any smaller multiple p·k (with k < p) has a factor smaller than p, so an earlier pass already crossed it. For p = 5 only 25 is new — 10, 15, 20 were removed by the 2-pass and 3-pass.
  • The outer loop stops at p * p > n — same sqrt(n) logic as the single prime check.

Time is about O(n log log n) — in practice, "nearly linear", and vastly faster than n individual prime checks.

Q2. How many primes are there below 20?
2, 3, 5, 7, 11, 13, 17, 19 — eight primes.

Drill — sieve by hand, count primes below 50

🟡 L2

Run the sieve mentally for the numbers 2..49. Which primes take a turn crossing out multiples (hint: up to what value of p), and how many primes survive below 50?

Show Solution

7 · 7 = 49 <= 49 — check the threshold: 7 does get a turn, and its only new victim is 49 itself. Primes below 50: 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47 → 15 primes.

Factors of a number

All divisors of n can be found with the same sqrt(n) trick — walk up to sqrt(n) and collect both members of each pair:

Python
def factors(n):
    small, large = [], []
    i = 1
    while i * i <= n:
        if n % i == 0:
            small.append(i)
            if i != n // i:          # avoid duplicating a square root
                large.append(n // i)
        i += 1
    return small + large[::-1]       # sorted: smalls up, then larges down

print(factors(36))
# [1, 2, 3, 4, 6, 9, 12, 18, 36]

For 36 the pairs are (1, 36), (2, 18), (3, 12), (4, 9), (6, 6) — the middle pair meets at the root, and the guard i != n // i keeps 6 from being collected twice. Nine factors total (an odd count is the fingerprint of a perfect square).

Drill — factors of 28

🟢 L1

List all factors of 28 and say whether the count is even or odd.

Show Solution

Pairs: (1, 28), (2, 14), (4, 7) → factors 1, 2, 4, 7, 14, 28 — 6 factors, even count (28 is not a perfect square). Bonus: 28 is a perfect number — its factors except itself sum to 1 + 2 + 4 + 7 + 14 = 28.

Digit tricks — sum, reverse, palindrome

Two primitive moves unlock every digit problem. For any positive integer n:

  • n % 10 → the last digit.
  • n // 10 → the number with the last digit chopped off.
Python
def digit_sum(n):
    total = 0
    while n > 0:
        total += n % 10   # take last digit
        n //= 10          # drop it
    return total

print(digit_sum(1234))    # 10
print(digit_sum(9875))    # 29

Reversing a number reuses the same two moves, but builds a new number instead of a sum: each step shifts the collected digits left (multiply by 10) and appends the next last digit.

Python
def reverse_number(n):
    rev = 0
    while n > 0:
        rev = rev * 10 + n % 10
        n //= 10
    return rev

print(reverse_number(472))    # 274
print(reverse_number(1200))   # 21  (leading zeros vanish)
Dry Run — reverse_number(472)
Start:          rev = 0,   n = 472
n % 10 = 2 → rev = 0 * 10 + 2 = 2,    n = 47
n % 10 = 7 → rev = 2 * 10 + 7 = 27,   n = 4
n % 10 = 4 → rev = 27 * 10 + 4 = 274, n = 0  → stop → 274 ✅

A palindrome number reads the same both ways, so the test is one comparison after reversing:

Python
def is_palindrome_number(n):
    if n < 0:
        return False            # negatives read backwards with a minus sign
    return n == reverse_number(n)

print(is_palindrome_number(12321))   # True
print(is_palindrome_number(123))     # False
print(is_palindrome_number(-121))    # False (convention: 121- is not equal)

Drill — digit gymnastics

🟢 L1

Without running code: (a) digit_sum(9875)? (b) reverse_number(1200)? (c) is 1001 a palindrome number?

Show Solution

(a) 9+8+7+5 = 29. (b) 0021 → the number 21 (a reversed number is still a number, leading zeros are dropped). (c) reverse of 1001 is 1001 → True.

Q3. In reverse_number, what does rev * 10 + n % 10 do?
Multiplying by 10 makes room for a new last digit; n % 10 supplies it. The n //= 10 on the next line does the removal.

Fast power — O(log n) exponentiation Optional

Computing a^n by multiplying n times is O(n). Notice that a^8 = (a^4)^2 = ((a^2)^2)^2 — squaring repeatedly needs only ~log n steps. The binary view of the exponent tells you which partial powers to use: 13 = 1101 in binary, so a^13 = a^8 * a^4 * a^1 (the powers matching the 1-bits).

Python
def fast_power(base, exp):
    result = 1
    while exp > 0:
        if exp % 2 == 1:      # current lowest bit of exp is 1
            result *= base    # take this power into the product
        base *= base          # square: base, base^2, base^4, base^8, ...
        exp //= 2             # shift exponent right by one bit
    return result

print(fast_power(2, 10))    # 1024
print(fast_power(3, 13))    # 1594323
Dry Run — fast_power(2, 10), 10 = 1010 in binary
Start:              result = 1, base = 2,  exp = 10 (even)
exp = 5:            base = 4,   exp odd → result = 1 * 4  = 4
exp = 2:            base = 16,  even → no change to result
exp = 1:            base = 256, odd  → result = 4 * 256 = 1024
exp = 0 → stop → 1024 ✅   (took 4 rounds instead of 10 multiplications)

Drill — trace fast_power(3, 13)

🟡 L2

13 = 1101. Predict which powers of 3 get multiplied, then verify: the answer is 1594323.

Show Solution
Dry Run — fast_power(3, 13)
exp = 13 (odd):  result = 1 * 3 = 3;           base = 9
exp = 6  (even): result = 3;                  base = 81
exp = 3  (odd):  result = 3 * 81 = 243;       base = 6561
exp = 1  (odd):  result = 243 * 6561 = 1594323;  base = 43046721
exp = 0 → stop ✅

The multiplied powers are exactly those matching the 1-bits of 13 = 8 + 4 + 1: 3^1 * 3^4 * 3^8 = 3 * 81 * 6561 = 1594323. The intermediate base values (3, 9, 81, 6561) are 3^1, 3^2, 3^4, 3^8.

Modular arithmetic — what % really gives you

a % m is the remainder after dividing a by m. 17 % 5 = 2, 10 % 5 = 0, 3 % 10 = 3. Three uses you will hit constantly:

  • Digits and parity: n % 10 is the last digit, n % 2 tests even/odd (used above).
  • Wrap-around: the next index in a circular array of length L is (i + 1) % L — index L wraps back to 0.
  • Keeping numbers small: the star of the show, next paragraph.

The property that makes modular arithmetic work piecewise: you may apply % m early and often, because

The two rules you need
(a + b) % m  ==  ((a % m) + (b % m)) % m
(a * b) % m  ==  ((a % m) * (b % m)) % m

Example: (1234 * 5678) % 1000 — the product is 7,006,652, so the answer is 652. Or the modular way: 234 * 678 % 1000 = 158,652 % 1000 = 652. Same result, smaller intermediates.

Concept — why judges ask for the answer mod 10^9 + 7

Many counting problems have astronomically large answers — the number of ways to order 20 items (20 factorial) is already 2,432,902,008,176,640,000, and by item 30 it no longer fits in a 64-bit integer. Languages like C++ and Java would overflow, so instead of exact answers, judges ask for the answer modulo 1,000,000,007: the remainder after dividing by that constant. The value 10^9 + 7 is chosen because it is prime (which keeps division-like operations well-defined in modular arithmetic) and because products of two numbers below it still fit in 64-bit intermediates. In Python integers never overflow — but the judge still expects the reduced value, so you apply % 1_000_000_007 after every multiplication or addition, exactly as the two rules above allow. No theory needed: just reduce early, reduce often.

Example: the product of the numbers 1 through 20, taken mod 1,000,000,007, is 146,326,063 — that remainder, not the 19-digit true value, is what your code should return.

Drill — reduce early

🟢 L1

Compute (999_999_937 * 4) % 1_000_000_007 the smart way (reduce the 4 first? or note 999999937 = 10^9 + 7 - 70).

Show Solution

999,999,937 = 1,000,000,007 - 70, so 4 copies of it are 4 copies of (-70) modulo the constant: -280 mod 1,000,000,007 = 1,000,000,007 - 280 = 999,999,727. Check with direct rules: (999_999_937 % 1_000_000_007) * 4 % 1_000_000_007 gives the same value.

Q4. Why do competitive judges ask for answers modulo 10^9 + 7?
Counts like factorials grow astronomically; the prime modulus 10^9+7 keeps every intermediate representable in 64-bit arithmetic for C++/Java. Python just needs the final reduced value to match.

What You Learned

  • GCD via Euclid — (a, b) → (b, a % b) until 0; LCM from GCD with a // g * b.
  • Prime test by trial division to sqrt(n) (divisors pair up); sieve for all primes up to n.
  • All factors of n in O(sqrt(n)) by collecting divisor pairs.
  • Digit moves: n % 10 takes the last digit, n //= 10 drops it — enough for digit sum, reverse, palindrome.
  • Fast power squares its way to O(log n); modular rules (a+b) % m and (a·b) % m keep numbers small.

Pattern Recognition

Clues that a math tool applies: "greatest common divisor / splits into equal groups" → GCD; "smallest number both divide" → LCM; "count primes" → sieve; "sum of divisors / is it prime" → sqrt(n) loop; "reverse the digits / digit sum" → the two digit moves; "print the answer modulo 10^9+7" → reduce after every operation. When a numeric statement looks like it needs loops inside loops, check whether one of these six tools collapses it.

Complexity Summary

ToolTimeNotes
GCD (Euclid)O(log min(a, b))numbers shrink at least by half every two steps
LCM via GCDO(log min(a, b))one GCD call
Prime checkO(sqrt(n))odd divisors only
Sieve of EratosthenesO(n log log n)space O(n)
All factorsO(sqrt(n))collected in pairs
Digit sum / reverse / palindromeO(number of digits)= O(log n)
Fast powerO(log n)vs O(n) naive

Common Mistakes

  • Treating 0 and 1 as prime — the prime test must start by rejecting n < 2.
  • Looping range(2, n) for the prime check — correct but 1000x slower than needed at n = 10^6.
  • Writing lcm = a * b // gcd with huge a and b in overflow-prone languages; divide first.
  • Forgetting i != n // i when collecting factors — perfect squares get their root twice.
  • Reversing negative numbers with the digit loop (-121 % 10 is 9 in Python, not 1!) — handle the sign separately.
  • Applying % 10^9+7 only at the very end — with big intermediate products that is fine in Python but conceptually wrong in overflow languages; reduce after each step.

Mini Quiz

Q1. What is lcm(4, 6)?
gcd(4, 6) = 2, so lcm = 4 * 6 / 2 = 12.
Q2. Why is it enough to test divisors up to sqrt(n) for primality?
If both factors exceeded sqrt(n) their product would exceed n — contradiction. So a composite always has a factor <= sqrt(n).
Q3. The sieve crosses out multiples starting from p * p. Why is anything below p * p already crossed?
If k = p · m with m < p, then k is also a multiple of m (or of a smaller factor of m), so an earlier prime already removed it.
Q4. reverse_number(1200) returns...
The algorithm appends digits while building an integer — 0021 is the integer 21. Leading zeros cannot exist in an int.

Practice Problems

Practice 1 — Perfect number check

🟢 L1

A perfect number equals the sum of its proper divisors (all factors except itself). 28 = 1 + 2 + 4 + 7 + 14 → perfect. Write the check.

Show Hint

Reuse the sqrt(n) factor-pair loop and sum both partners (skip n itself).

Show Approach

Start the sum at 1 (1 is always a proper divisor for n > 1), walk i from 2 to sqrt(n), add i and n//i for each hit, then compare with n.

Show Solution
Python
def is_perfect(n):
    if n < 2:
        return False
    total = 1                    # 1 divides everything
    i = 2
    while i * i <= n:
        if n % i == 0:
            total += i
            if i != n // i:
                total += n // i
        i += 1
    return total == n

print(is_perfect(28))    # True
print(is_perfect(12))    # False (1+2+3+4+6 = 16)

Practice 2 — Count digits

🟢 L1

Count the digits of a positive integer without converting to a string. 47291 → 5.

Show Hint

Each n //= 10 removes exactly one digit.

Show Approach

Loop while n > 0, counting one per chop. (Careful: the count for n = 0 is 1 digit — handle it if needed.)

Show Solution
Python
def count_digits(n):
    if n == 0:
        return 1
    count = 0
    while n > 0:
        n //= 10
        count += 1
    return count

print(count_digits(47291))   # 5
print(count_digits(7))       # 1

Practice 3 — Armstrong number (3-digit)

🟡 L2

A 3-digit Armstrong number equals the sum of the cubes of its digits: 153 = 1 + 125 + 27 → True. Write the check for any 3-digit n.

Show Hint

Extract the three digits with % and //, cube each with d ** 3.

Show Approach

ones = n % 10; tens = (n // 10) % 10; hundreds = n // 100; compare the sum of cubes to n.

Show Solution
Python
def is_armstrong3(n):
    ones = n % 10
    tens = (n // 10) % 10
    hundreds = n // 100
    return ones ** 3 + tens ** 3 + hundreds ** 3 == n

print(is_armstrong3(153))    # True
print(is_armstrong3(370))    # True  (27 + 343 + 0)
print(is_armstrong3(152))    # False

Challenge Problem

Challenge — Smallest number divisible by every integer 1..10

🟠 L3 · Recognize

Find the smallest positive integer that is divisible by all of 1, 2, 3, ..., 10 — using your own gcd/lcm functions (no math.lcm).

Show Hint

"Divisible by all of them" means divisible by their LCM. LCM of many numbers folds left to right: lcm(lcm(lcm(1, 2), 3), 4)...

Show Approach

Keep a running answer starting at 1; for k in 2..10 replace answer with lcm(answer, k). The running value only ever grows to what is strictly needed.

Show Solution
Python
def gcd(a, b):
    while b:
        a, b = b, a % b
    return a

def lcm(a, b):
    return a // gcd(a, b) * b

answer = 1
for k in range(2, 11):
    answer = lcm(answer, k)
print(answer)    # 2520

The running LCM goes 2, 6, 12, 60, 60, 420, 840, 2520, 2520 — the final step changes nothing because 10 already divides 2520. 2520 is the answer.

Revision Checklist

PART 23

Core DSA Pattern Library

Eighteen patterns. One page each. Most beginner problems are one of these wearing a costume.

You have now met every pattern in this book individually — hashing in Part 5, two pointers in Part 6, windows in Part 7, and so on. This part compresses all of them into one reference library: each pattern gets a card with its keywords, its mental model, a skeleton, a tested Python template, and the mistakes people make with it. When you are stuck on a problem, you should be able to scan these cards the way a doctor scans a differential diagnosis.

Read the statement
→
Spot the keywords
→
Name the pattern
→
Adapt the template

How to read a pattern card

Every card has identical fields in identical order, so scanning becomes a skill: What it means and When to recognize it trigger the match; Keywords are the statement phrases that signal it; Typical structure and Mental model give you the shape and the picture; Skeleton is the pseudocode you rewrite from memory; the Python template is minimal, commented, and executed against the examples shown; Common mistakes, Example problem (with a link to its full chapter), Complexity, and Variations round it out. Drill the middle rows, skim the rest.

Tip — drill the skeletons, not the solutions

Solutions differ; skeletons repeat. If you can write the 18 skeletons below on a blank page from memory, you can start every problem with the hard part already done. That is the single highest-value memorization in this book.

Same problem, three patterns

Before the library, one demonstration that pattern choice is the solution. Classic problem: given nums and a target, return the indices of two numbers that add up to target. Watch what each pattern buys you.

nums = [2, 7, 11, 15], target = 9 → [0, 1] because 2 + 7 = 9. Exactly one valid answer exists. The question that picks your pattern: what work am I repeating? (That question is the first tool in Part 33.)

Idea

Check every pair. For each element, search the rest of the array again for its partner — that search is the repeated work.

Python
def two_sum_brute(nums, target):
    n = len(nums)
    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return [i, j]
    return []

Correct, but O(n²). Fine for n ≤ 1000; dead at n ≤ 10⁵ (see Part 32).

Idea

Sorting puts small numbers left and big numbers right, so a squeeze from both ends finds the pair in one pass (Part 6). Cost: O(n log n) for the sort — and you must carry original indices if the answer needs them.

Python
def two_sum_sorted(nums, target):
    pairs = sorted(zip(nums, range(len(nums))))   # keep original indices
    left, right = 0, len(nums) - 1
    while left < right:
        s = pairs[left][0] + pairs[right][0]
        if s == target:
            return [pairs[left][1], pairs[right][1]]
        if s < target:
            left += 1        # need a bigger sum
        else:
            right -= 1       # need a smaller sum
    return []
Idea

Ask a different question: have I already seen this element’s partner? One pass, and every “search the rest of the array” becomes an O(1) dictionary lookup (Part 5). The repeated work — re-searching for partners — disappears entirely.

Python
def two_sum(nums, target):
    seen = {}                     # value -> index where we saw it
    for i, x in enumerate(nums):
        if target - x in seen:
            return [seen[target - x], i]
        seen[x] = i
    return []

print(two_sum([2, 7, 11, 15], 9))   # [0, 1]
Pattern Insight

Brute force repeats a search; hashing replaces that search with a lookup. Every pattern in the library below is a story about eliminating one specific repeated work. Optimization is the process — see Part 24.

The eighteen patterns

P1 · Frequency Map

Pattern
What it means

Walk the input once and tally each distinct item in a dictionary: item → how many times it appeared. Any question about how many or which one occurs most becomes a lookup.

When to recognize it

The statement asks to count, compare makeups of two things (anagrams), or find the most/least common item. Order rarely matters; multiplicity does.

Keywords in the statement

count occurrences most frequent anagram how many times frequency same letters

Typical structure

One loop to build the map, one loop (or one lookup) to use it.

Mental model

A tally sheet on a wall: one row per distinct item; every arrival adds a tick. Answering “how many a’s?” means glancing at a row — not rescanning the crowd.

Skeleton
Pseudocode
freq = empty dictionary
for each item in input:
    freq[item] = freq.get(item, 0) + 1
answer = scan freq for the key(s) you need
Python template
Python
def build_freq(items):
    freq = {}
    for x in items:
        freq[x] = freq.get(x, 0) + 1   # .get: count starts at 0 if unseen
    return freq

def most_frequent(items):
    freq = build_freq(items)
    best, best_count = None, 0
    for key, count in freq.items():
        if count > best_count:
            best, best_count = key, count
    return best

print(build_freq(["a", "b", "a", "c", "b", "a"]))  # {'a': 3, 'b': 2, 'c': 1}
print(most_frequent(["a", "b", "a", "c", "b", "a"]))  # 'a'
Common mistakes
  • Writing freq[x] += 1 on a missing key → KeyError. Use freq.get(x, 0) + 1.
  • Using a frequency map where only existence matters — a set (P2) is simpler.
  • Checking freq[a] == freq[b] for keys that might not exist instead of using .get(key, 0).
Example problem

Are two words anagrams? Count letters in each and compare the maps — covered in Part 4 · Strings.

Complexity

Time O(n) to build · Space O(k) for k distinct items.

Variations
  • Indexed map value → index — the Two Sum workhorse.
  • Grouping map key → list of items — group anagrams, group by remainder.
  • Counter from collections does the build in one line — know the manual version anyway.

P2 · Seen Set

Pattern
What it means

Carry a set of everything you have already processed, so the question “have I met this before?” costs O(1). It is a frequency map with the counts thrown away.

When to recognize it

You need membership tests, duplicate detection, or “does a partner of this item exist earlier?” — and you never need the count.

Keywords in the statement

duplicate seen before unique distinct contains pair that sums to

Typical structure

One pass: ask the set, then add. The order ask → add matters.

Mental model

Pockets. As you walk a crowd you put every face in a pocket; before pocketing the next face you pat your pockets: “have I seen this one?”

Skeleton
Pseudocode
seen = empty set
for each item in input:
    if item (or its partner) is in seen:
        report / return
    add item to seen
Python template
Python
def has_duplicate(nums):
    seen = set()
    for x in nums:
        if x in seen:
            return True
        seen.add(x)
    return False

def has_pair_with_sum(nums, target):
    # ask about the COMPLEMENT before adding x — avoids pairing x with itself
    seen = set()
    for x in nums:
        if target - x in seen:
            return True
        seen.add(x)
    return False

print(has_duplicate([1, 4, 2, 4]))        # True
print(has_pair_with_sum([1, 5, 3], 10))   # False  (5 would need another 5)
Common mistakes
  • Using a list for membership checks — x in some_list is O(n); the whole speedup evaporates (Part 32).
  • Adding first, then asking — turns [5] with target = 10 into a fake pair of 5 + 5.
  • Iterating over the set while modifying it.
Example problem

Does the array contain any duplicate? One pass with a seen set — covered in Part 5 · Hashing.

Complexity

Time O(n) · Space O(n).

Variations
  • Seen map value → index when you need where you saw it.
  • Prefix-sum seen set — detect a subarray summing to 0 (a repeat means a zero-sum stretch).

P3 · Two Pointers

Pattern
What it means

Two indices walk the data intelligently — from both ends toward each other, or in the same direction at different jobs — so every element is visited once instead of rescan in nested loops.

When to recognize it

The data is sorted (or order does not matter and you may sort), and you are looking for a pair meeting a condition, or reversing / comparing from the outside in.

Keywords in the statement

sorted array pair with target sum palindrome reverse in-place two sequences

Typical structure

left = 0, right = n - 1; a while loop moves exactly one pointer per step based on a comparison.

Mental model

Squeezing a tube of toothpaste from both ends — or two people walking a sorted line toward each other, each step discarding one impossible candidate.

Skeleton
Pseudocode
left = 0, right = last index
while left < right:
    evaluate (item[left], item[right])
    if condition met:      record and stop
    if sum too small:      left += 1     (need bigger)
    else:                  right -= 1    (need smaller)
Python template
Python
def pair_sum_sorted(nums, target):
    # nums MUST be sorted — that is what makes moving one pointer safe
    left, right = 0, len(nums) - 1
    while left < right:
        s = nums[left] + nums[right]
        if s == target:
            return (left, right)
        if s < target:
            left += 1      # every pair using old left is too small too
        else:
            right -= 1     # every pair using old right is too big too
    return None

print(pair_sum_sorted([1, 3, 4, 6, 8], 10))   # (2, 3) → 4 + 6
Common mistakes
  • Running it on unsorted data — the “discard one side” logic needs order to be sound.
  • Loop condition left <= right when a pair needs two different elements.
  • Moving both pointers at once “to be faster” — you can skip a valid pair.
Example problem

Find a pair summing to target in a sorted array — covered in Part 6 · Two Pointers.

Complexity

Time O(n) (plus a sort first if needed) · Space O(1).

Variations
  • Same-direction pair — read pointer + write pointer for in-place filtering (move zeroes).
  • Two sequences — one pointer per array (merge sorted lists).
  • Three pointers — Dutch flag partitioning (Part 9).

P4 · Sliding Window

Pattern
What it means

Maintain a contiguous stretch of the input that grows on the right and shrinks on the left, updating a small summary (set, counts, sum) as it moves — so you never recompute a window from scratch.

When to recognize it

The question is about a contiguous subarray or substring that is longest / shortest / counts of, under some condition — and the condition behaves nicely when the window grows (adding elements can’t fix a violation).

Keywords in the statement

contiguous substring subarray longest shortest at most k no repeats window

Typical structure

for right adds an element; a while shrinks from left while the window is invalid; then record the answer.

Mental model

A caterpillar inching along the array: head stretches forward; when the body breaks a rule, the tail catches up.

Skeleton
Pseudocode
left = 0, best = 0, window state = empty
for right in 0..n-1:
    add input[right] to window state
    while window is invalid:
        remove input[left] from state
        left += 1
    best = max(best, right - left + 1)
Python template
Python
def longest_unique_substring(s):
    seen = set()          # characters currently inside the window
    left = 0
    best = 0
    for right in range(len(s)):
        while s[right] in seen:    # s[right] would be a repeat
            seen.remove(s[left])   # shrink from the left until it is not
            left += 1
        seen.add(s[right])
        best = max(best, right - left + 1)
    return best

print(longest_unique_substring("pwwkew"))   # 3  ("wke")
Common mistakes
  • Updating best inside the shrink loop instead of after the window is valid again.
  • Forgetting + 1: indices left..right contain right - left + 1 elements.
  • Using a window when numbers can be negative — a bigger window then can shrink the sum; switch to prefix sums (P5).
Example problem

Longest substring without repeating characters — covered in Part 7 · Sliding Window.

Complexity

Time O(n) — every element enters and leaves the window at most once · Space O(k).

Variations
  • Fixed window of size k — no shrink loop, just slide.
  • At-most-k distinct — window state is a counts dict; shrink while len(counts) > k.
  • Count subarrays — add right - left + 1 (or left) per step instead of taking a max.

P5 · Prefix Sum

Pattern
What it means

Precompute running totals once, so the sum of any range becomes a subtraction of two stored values: sum(i..j) = prefix[j+1] - prefix[i].

When to recognize it

Many questions about range or subarray sums, or you notice yourself re-summing overlapping slices. Also the fix when a sliding window fails on negative numbers.

Keywords in the statement

range sum sum from i to j subarray sum running total count subarrays many queries

Typical structure

Build prefix with a leading 0; answer each query with one subtraction.

Mental model

Mileage markers on a highway. The distance between exit 2 and exit 7 is odometer(7) - odometer(2) — nobody re-drives the road.

Skeleton
Pseudocode
prefix[0] = 0
for i in 0..n-1:
    prefix[i+1] = prefix[i] + input[i]
sum(i..j)   = prefix[j+1] - prefix[i]
Python template
Python
def build_prefix(nums):
    prefix = [0]                    # prefix[i] = sum of the first i elements
    for x in nums:
        prefix.append(prefix[-1] + x)
    return prefix

def range_sum(prefix, i, j):        # sum of nums[i..j], both inclusive
    return prefix[j + 1] - prefix[i]

nums = [2, 4, 1, 7]
prefix = build_prefix(nums)         # [0, 2, 6, 7, 14]
print(prefix)                       # [0, 2, 6, 7, 14]
print(range_sum(prefix, 1, 3))      # 4 + 1 + 7 = 12
Common mistakes
  • Mixing the two conventions (prefix[i] = first i elements vs first i+1) inside one solution — pick one and stick to it.
  • Off-by-one in the subtraction: the end index gets j + 1.
  • Building the prefix inside the query loop — you pay O(n) per query and keep nothing.
Example problem

Answer thousands of “sum from i to j” questions on one array — covered in Part 8 · Prefix Sum.

Complexity

Build O(n) · each query O(1) · Space O(n).

Variations
  • Prefix max / min — “best so far” scans (stock profit, leaders).
  • Prefix counts + hashmap — count subarrays summing to k via total - k lookups.
  • Prefix XOR — XOR of any range by the same subtraction idea.

P6 · Sorting + Scan

Pattern
What it means

If the answer ignores original positions, sort first: duplicates become adjacent, closest values become neighbors — then one linear scan finishes the job.

When to recognize it

The statement lets you reorder (“return any order”, or the answer is about values, not positions), and the brute force compares many pairs — sorting shrinks the comparisons to neighbors.

Keywords in the statement

order doesn’t matter any order closest minimum difference duplicates can rearrange

Typical structure

sorted_nums = sorted(nums) then a single for comparing each element with its neighbor.

Mental model

Sort a messy drawer of screws into size order — matching screws end up side by side, so you find pairs by looking right, not by trying every combination.

Skeleton
Pseudocode
sorted_input = sort(input)          # if original order is not needed
for i in 1..n-1:
    relate sorted_input[i] to sorted_input[i-1]
    keep the best / detect the event
Python template
Python
def min_abs_difference(nums):
    nums = sorted(nums)             # sorted() copies — input stays untouched
    best = nums[1] - nums[0]        # after sorting, every value is >= the last
    for i in range(1, len(nums)):
        best = min(best, nums[i] - nums[i-1])
    return best

print(min_abs_difference([5, 1, 19, 11]))   # 4  (from 5 and 1)
Common mistakes
  • Using nums.sort() when the caller still needs the original order — nums = nums.sort() is worse: it assigns None.
  • Forgetting the sort itself costs O(n log n) — claiming “O(n)” for the whole solution.
  • Sorting when positions matter — windows, stock profits and “first occurrence” problems break under sorting.
Example problem

Find duplicates by comparing adjacent elements after sorting — covered in Part 9 · Sorting.

Complexity

Time O(n log n) · Space O(1) in-place or O(n) with sorted().

Variations
  • Sort + two pointers — pair and triplet targets.
  • Sort by key — the gateway to greedy (P16) and intervals (P17).
  • Sort + binary search — sort once, answer many membership queries.

P7 · Binary Search

Pattern
What it means

On data where a condition flips from NO to YES exactly once (sorted arrays, or any monotonic question), probe the middle and throw away half the search space every step.

When to recognize it

Sorted input, an O(log n) requirement, or a question like “smallest speed that still finishes in time” — a yes/no predicate that stays true once it becomes true.

Keywords in the statement

sorted O(log n) first / last occurrence minimum speed / capacity search boundary

Typical structure

lo, hi bounds; mid = (lo + hi) // 2; discard the impossible half each iteration.

Mental model

The 1-to-100 guessing game. Every “higher” / “lower” halves what is left — 30 guesses cover a billion numbers.

Skeleton
Pseudocode
lo = 0, hi = n - 1
while lo <= hi:
    mid = (lo + hi) // 2
    if input[mid] is the target:  return mid
    if input[mid] too small:      lo = mid + 1
    else:                         hi = mid - 1
return not found
Python template
Python
def binary_search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:                  # hi = n-1 pairs with <= and mid ± 1
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        if nums[mid] < target:
            lo = mid + 1             # mid is checked, discard it too
        else:
            hi = mid - 1
    return -1

print(binary_search([1, 3, 5, 7, 9, 11], 9))   # 4
print(binary_search([1, 3, 5, 7, 9, 11], 4))   # -1
Common mistakes
  • Mixing conventions: hi = n - 1 belongs with while lo <= hi and mid ± 1; hi = n belongs with while lo < hi and lo = mid.
  • The infinite loop lo = mid when hi = lo + 1 — mid rounds down and nothing shrinks.
  • Binary-searching data that is not monotonic — garbage in, garbage out.
Example problem

Search a sorted array, then “minimum eating speed to finish in h hours” (binary search on the answer) — covered in Part 10 · Binary Search.

Complexity

Time O(log n) · Space O(1).

Variations
  • First / last occurrence — keep searching after a hit; record and keep shrinking.
  • Binary search on the answer — search the space of possible answers, not the array.

P8 · Stack

Pattern
What it means

Last-in, first-out. Push unfinished items; when a new item arrives, it interacts with the most recent unfinished one. A stack is a to-do pile where only the top is visible.

When to recognize it

Matching or nesting (“most recent opener must close first”), undo behavior, or a right-to-left scan where older candidates become irrelevant.

Keywords in the statement

valid parentheses matching nested undo backspace most recent next greater

Typical structure

One pass; each item is either pushed, or pops matching items off the top.

Mental model

A pile of plates: you may only add or remove the top plate. Or browser history “back” — the page you just left.

Skeleton
Pseudocode
stack = empty list
for each item in input:
    if item opens something:
        push item
    else:
        if stack empty or top does not match:  FAIL
        pop the top
at the end: success only if stack is empty
Python template
Python
def is_valid_parentheses(s):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in s:
        if ch in "([{":
            stack.append(ch)
        else:
            # a closer must match the LATEST unmatched opener
            if not stack or stack[-1] != pairs[ch]:
                return False
            stack.pop()
    return len(stack) == 0     # "(()" fails here: an opener is left over

print(is_valid_parentheses("([{}])"))   # True
print(is_valid_parentheses("(]"))       # False
Common mistakes
  • Popping (or peeking) an empty stack — always test if not stack first.
  • Forgetting the final emptiness check — "((" must fail even though nothing mismatched mid-scan.
  • Using list.pop(0) somewhere out of habit — that is queue behavior and it is O(n) (Part 32).
Example problem

Valid parentheses, then next-greater-element with a stack of candidates — covered in Part 11 · Stack.

Complexity

Time O(n) — each item is pushed and popped at most once · Space O(n).

Variations
  • Monotonic stack — keep the stack sorted; answers “next greater” questions.
  • Two stacks — undo/redo, queue-from-stacks, min-stack.
  • Expression evaluation — postfix arithmetic.

P9 · Queue

Pattern
What it means

First-in, first-out. Items are processed strictly in arrival order, and processing an item may enqueue new items behind it. A queue is fairness embodied.

When to recognize it

Simulation of a real line, turn-based processing, level-by-level exploration (BFS, P13), or anything where later arrivals must not overtake earlier ones.

Keywords in the statement

in order first come first served process tasks level by level simulation round robin

Typical structure

A deque; loop while it is not empty: pop from the left, do work, append follow-ups to the right.

Mental model

A checkout line at a supermarket: the person at the front is served next; new people join at the back, no cutting.

Skeleton
Pseudocode
queue = deque(seed items)
while queue not empty:
    item = queue.popleft()          # earliest arrival first
    process item
    for each follow-up of item:
        queue.append(follow_up)
Python template
Python
from collections import deque

def first_n_binary(n):
    q = deque(["1"])                # smaller numbers were created earlier,
    out = []                        # so FIFO order keeps the output sorted
    for _ in range(n):
        cur = q.popleft()
        out.append(cur)
        q.append(cur + "0")         # children are processed after everything
        q.append(cur + "1")         # created before them
    return out

print(first_n_binary(5))   # ['1', '10', '11', '100', '101']
Common mistakes
  • Using list.pop(0) as a queue — every pop shifts all n elements: O(n) each time (Part 32).
  • Forgetting the “while not empty” loop condition and processing a fixed count when the queue grows.
  • In graph BFS, marking nodes visited when they are popped instead of when they are enqueued — the queue fills with duplicates.
Example problem

Generate binary numbers 1 to n in order — covered in Part 12 · Queue & Deque.

Complexity

O(1) per operation with collections.deque · total O(n) for n items.

Variations
  • Deque — push/pop allowed at both ends (sliding-window candidates).
  • Round-robin — pop, do partial work, re-append unfinished items.
  • BFS frontier — see P13; the queue holds whole levels.

P10 · Fast / Slow Pointer

Pattern
What it means

Two pointers travel the same sequence in the same direction at different speeds. The gap between them is a tool: constant gaps find “kth from end”, speed differences find cycles and middles.

When to recognize it

Linked list questions that ask for the middle, a cycle, or an element relative to the end — especially with an O(1) space requirement.

Keywords in the statement

linked list cycle middle kth from the end O(1) extra space

Typical structure

slow moves 1 step per round, fast moves 2; loop while fast and fast.next exist.

Mental model

Two runners on a circular track: the fast one must lap the slow one. On a straight track the fast one just runs off the end.

Skeleton
Pseudocode
slow = head, fast = head
while fast exists and fast.next exists:
    slow = slow.next           # 1 step
    fast = fast.next.next      # 2 steps
    if slow is fast:           # they can only meet on a cycle
        return True
return False                   # fast fell off the end: no cycle
Python template
Python
class Node:
    def __init__(self, val, nxt=None):
        self.val = val
        self.next = nxt

def has_cycle(head):
    slow = fast = head
    while fast and fast.next:      # fast must be safe before 2 steps
        slow = slow.next
        fast = fast.next.next
        if slow is fast:           # gap shrank to 0: there is a cycle
            return True
    return False

# 1 -> 2 -> 3 -> 4 -> points back to 2
a, b, c, d = Node(1), Node(2), Node(3), Node(4)
a.next, b.next, c.next, d.next = b, c, d, b
print(has_cycle(a))   # True
Common mistakes
  • Writing fast.next.next without checking fast.next exists — AttributeError on even-length lists.
  • Using == instead of is — node equality by value can be true for different nodes.
  • Reaching for a hash set of visited nodes when O(1) space is requested (it works, but it is the wrong tool on a budget).
Example problem

Detect a cycle in a linked list, then find the middle node — covered in Part 13 · Linked List.

Complexity

Time O(n) · Space O(1).

Variations
  • Middle — stop when fast hits the end; slow is the middle.
  • Kth from end — send fast k steps ahead first, then move both.
  • Cycle start — after meeting, walk one pointer from head; they meet at the entrance.

P11 · Recursion

Pattern
What it means

Solve a problem by solving a smaller copy of the same problem and combining the result. The function calls itself with input that strictly shrinks toward a base case.

When to recognize it

The data is self-similar (trees, nested lists, directories) or the problem is naturally defined in terms of itself (“n! = n × (n-1)!”).

Keywords in the statement

nested tree defined in terms of itself each node / each level recursively

Typical structure

Base case first; recursive case calls the same function on a strictly smaller input and combines.

Mental model

Russian dolls: open the doll, hand the inner doll to a clone of yourself, take back the answer, combine. Trust the recursion — do not mentally simulate every level.

Skeleton
Pseudocode
function solve(input):
    if input is tiny enough:     return direct answer      # base case
    smaller = shrink(input)
    sub = solve(smaller)                                   # trust it
    return combine(input-part, sub)
Python template
Python
def sum_list(nums, i=0):
    if i == len(nums):          # base case: nothing left to add
        return 0
    return nums[i] + sum_list(nums, i + 1)   # trust the smaller answer

print(sum_list([3, 1, 4]))    # 3 + (1 + (4 + 0)) = 8
Common mistakes
  • Missing or unreachable base case → RecursionError (Python caps depth near 1000).
  • Forgetting return in the recursive case — the answer arrives as None.
  • Slicing (nums[i+1:]) at every level — each slice copies, quietly turning O(n) into O(n²). Pass an index instead.
Example problem

Factorial and friends, then the call stack visualized — covered in Part 14 · Recursion.

Complexity

Time = number of calls · Space = O(depth) for the call stack.

Variations
  • Multiple calls — trees (P14, Part 16), naive Fibonacci.
  • Recursion + memo — cache results to kill repeated subproblems.
  • Recursion → explicit stack — same logic, no depth limit.

P12 · Backtracking

Pattern
What it means

Recursion that enumerates all possibilities: at each step choose one option, explore everything downstream of it, then undo the choice and try the next one. Recursion with a memory of the current path.

When to recognize it

The statement asks to generate, count, or search among all subsets / permutations / combinations / valid arrangements.

Keywords in the statement

all possible generate every subsets permutations combinations each choice

Typical structure

A helper carrying start (or used-flags) and the current path; choose → explore → undo inside a loop.

Mental model

Walking a maze of choices while laying down breadcrumbs: at a dead end you walk back, picking up each breadcrumb, so the maze is left exactly as you found it for the next branch.

Skeleton
Pseudocode
function backtrack(path, start):
    record path (a COPY) if it is a valid answer
    for i in start..n-1:
        choose input[i]        # path.append
        backtrack(path, i + 1) # explore
        unchoose input[i]      # path.pop   ← the step people forget
Python template
Python
def subsets(nums):
    result = []
    path = []

    def backtrack(start):
        result.append(path[:])          # record a COPY of the path
        for i in range(start, len(nums)):
            path.append(nums[i])        # choose
            backtrack(i + 1)            # explore (i+1: never reuse)
            path.pop()                  # undo — restore the exact state

    backtrack(0)
    return result

print(subsets([1, 2, 3]))
# [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
Common mistakes
  • Forgetting path.pop() — choices leak across branches and results corrupt.
  • Recording path itself instead of path[:] — every “answer” is the same final list.
  • Passing i instead of i + 1 — infinite loops / element reuse.
Example problem

Generate all subsets of a set — covered in Part 15 · Backtracking Basics.

Complexity

At least as big as the output: O(2ⁿ) subsets, O(n · n!) permutations.

Variations
  • Permutations — used-flags instead of start.
  • Combinations / sum targets — prune branches whose partial sum already fails.
  • Grid search — word search; un-mark the cell when backtracking.

P13 · BFS (Breadth-First Search)

Pattern
What it means

Explore a graph or grid in rings around the start: everything 1 step away, then 2, then 3. Because the queue preserves order, the first time you reach a node is via a shortest path (when all steps cost the same).

When to recognize it

“Minimum number of steps/moves”, “shortest path”, “nearest” on an unweighted graph or grid, or level-by-level processing of a tree.

Keywords in the statement

minimum steps shortest path fewest moves nearest level order unweighted

Typical structure

Queue seeded with the start; pop left, push unvisited neighbors; a visited set prevents loops.

Mental model

A ripple in a pond — the wavefront reaches nearby stones before distant ones, and it reaches each stone along the shortest route first.

Skeleton
Pseudocode
visited = {start}, queue = [(start, 0)]
while queue not empty:
    node, dist = queue.popleft()
    for each neighbor of node:
        if neighbor is the target: return dist + 1
        if neighbor not visited:
            mark visited NOW          # not when popped!
            queue.append((neighbor, dist + 1))
Python template
Python
from collections import deque

def shortest_steps(graph, start, end):
    # graph: {node: [neighbors]} — every step costs the same
    if start == end:
        return 0
    visited = {start}
    q = deque([(start, 0)])            # (node, steps taken so far)
    while q:
        node, steps = q.popleft()
        for nb in graph[node]:
            if nb == end:
                return steps + 1       # first arrival = fewest steps
            if nb not in visited:
                visited.add(nb)        # mark when ENQUEUED
                q.append((nb, steps + 1))
    return -1                          # end is unreachable

graph = {"A": ["B", "C"], "B": ["D"], "C": ["D"], "D": []}
print(shortest_steps(graph, "A", "D"))   # 2
Common mistakes
  • No visited set — the ripple loops forever between two neighbors.
  • Marking visited on pop: the same node enters the queue many times before it is popped.
  • Using BFS on weighted graphs and trusting the step count — equal edge costs are what make BFS correct.
Example problem

Fewest edges between two nodes; level-order tree traversal — covered in Part 18 · Graph Basics.

Complexity

Time O(V + E) · Space O(V).

Variations
  • Grid BFS — neighbors are up/down/left/right cells.
  • Level-by-level — freeze len(q) per round to process levels separately.
  • Multi-source BFS — seed the queue with many starts; ripple meets in the middle.

P14 · DFS (Depth-First Search)

Pattern
What it means

Explore by going deep: follow one path as far as it goes, backtrack to the last fork, try the next path. BFS asks “what is closest?”; DFS asks “what is all of it?”

When to recognize it

Counting or collecting everything reachable — connected components, islands, all paths in a tree, any-path-exists questions.

Keywords in the statement

connected reachable islands components explore all count regions

Typical structure

A recursive dfs(node) that marks visited and recurses into each unvisited neighbor; an outer loop restarts it from every unvisited node.

Mental model

Exploring a cave with a ball of string: dive into one tunnel to its end, then back up to the last fork and take the next tunnel. The string (the call stack) remembers the way out.

Skeleton
Pseudocode
function dfs(node):
    mark node visited
    for each neighbor of node:
        if neighbor not visited:
            dfs(neighbor)

count = 0
for each node in all nodes:
    if node not visited:
        dfs(node)          # explores one whole component
        count += 1         # one new component found
Python template
Python
def count_components(n, edges):
    graph = {i: [] for i in range(n)}      # adjacency list
    for a, b in edges:
        graph[a].append(b)
        graph[b].append(a)

    visited = set()

    def dfs(node):
        visited.add(node)
        for nb in graph[node]:
            if nb not in visited:
                dfs(nb)

    count = 0
    for node in range(n):
        if node not in visited:   # unexplored territory → new component
            dfs(node)
            count += 1
    return count

print(count_components(5, [[0, 1], [1, 2], [3, 4]]))   # 2
Common mistakes
  • Deep recursion on big grids → RecursionError; rewrite with an explicit stack when needed.
  • Forgetting the outer loop — one dfs call explores only one component.
  • Marking visited too late (after the loop), so a node is pushed/recursed several times.
Example problem

Count connected components; count islands in a grid — covered in Part 18 · Graph Basics.

Complexity

Time O(V + E) · Space O(V) plus recursion depth.

Variations
  • Grid DFS / flood fill — 4-direction neighbors, visited = set or repainted cells.
  • Tree traversals — preorder / inorder / postorder are all DFS (Part 16).
  • Iterative DFS — replace the call stack with a list used as a stack.

P15 · Heap / Top-K

Pattern
What it means

A structure that always hands you its current best element in O(1) and repairs itself in O(log n). For Top-K: keep a heap of size k holding the k best-so-far; its root is the boundary every newcomer must beat.

When to recognize it

“K largest / smallest”, “kth element”, “most frequent”, “k closest”, merging k sorted sources — especially on streams where you cannot sort everything.

Keywords in the statement

k largest kth top k most frequent k closest merge k sorted

Typical structure

Push every item; whenever the heap outgrows k, pop the boundary element. Answer = heap root.

Mental model

A bouncer at a club that holds exactly k people: anyone new must be better than the weakest person inside, and the weakest is ejected. The bouncer (root) always knows the weakest.

Skeleton
Pseudocode
heap = empty min-heap
for each item in input:
    push item into heap
    if heap size > k:
        pop the smallest          # evict the weakest of the k best
answer = heap root                # the kth largest overall
Python template
Python
import heapq

def kth_largest(nums, k):
    heap = []                       # min-heap holding the k best so far
    for x in nums:
        heapq.heappush(heap, x)
        if len(heap) > k:
            heapq.heappop(heap)     # evict the current weakest
    return heap[0]                  # weakest of the k best = kth largest

print(kth_largest([3, 2, 1, 5, 6, 4], 2))   # 5
Common mistakes
  • Forgetting heapq is a min-heap — for “k smallest” you keep a max-heap, simulated by negating values in and out (Part 17).
  • Building a heap of size n when size k is enough — costs O(n log n) instead of O(n log k).
  • Wrong tuple order in heap entries — the first field decides ranking.
Example problem

Kth largest element in an array — covered in Part 17 · Heap / Priority Queue.

Complexity

Time O(n log k) · Space O(k).

Variations
  • Top-K frequent — frequency map (P1) feeds the heap.
  • Heap of front-runners — merge k sorted lists ((value, list_id) entries).
  • heapify — turn a whole list into a heap in O(n) when k is close to n.

P16 · Greedy

Pattern
What it means

Sort to create an order of decisions, then make the locally best choice at every step and never reconsider. It works only when a local best cannot hurt the global best — and you should be able to argue why.

When to recognize it

“Minimum number of X” or “maximum number of Y” where a sort creates an order of choices (by end time, by size, by reach). Checkable: swapping any choice for the greedy one never helps.

Keywords in the statement

minimum number of maximum number of at most assign schedule fewest

Typical structure

Sort by one key; one scan; take an item exactly when it does not conflict with the last taken item.

Mental model

Packing a carry-on by picking the next item that leaves the most room — no re-planning, no regret. (It works for some item sets, fails for others — which is why greedy needs proof, see Part 19.)

Skeleton
Pseudocode
items = sort(items, by = chosen criterion)
taken = 0, last = minus infinity
for each item in items:
    if item fits after "last":
        take it                     # local best, never reconsidered
        last = item.end
answer = taken
Python template
Python
def max_activities(start, end):
    # classic result: choosing the activity that ENDS earliest
    # leaves the most room — so sort by end time
    acts = sorted(zip(start, end), key=lambda a: a[1])
    count = 0
    last_end = float("-inf")
    for s, e in acts:
        if s >= last_end:          # starts after the last chosen ends
            count += 1
            last_end = e
    return count

start = [1, 3, 0, 5, 8, 5]
end   = [2, 4, 6, 7, 9, 9]
print(max_activities(start, end))   # 4
Common mistakes
  • No proof, no test: greedily sorting by the wrong key (start time instead of end time) silently fails on some inputs.
  • Assuming greedy works for coin change — with coins [1, 3, 4] and target 6 it takes 4+1+1 (3 coins) instead of 3+3 (2 coins).
  • Never cross-checking against brute force on small random inputs.
Example problem

Maximum number of non-overlapping activities — covered in Part 19 · Greedy Thinking Basics.

Complexity

Time O(n log n) (the sort) + O(n) scan · Space O(1)–O(n).

Variations
  • Assign cookies — sort both arrays, two pointers.
  • Jump game — extend a frontier of reach.
  • Proof habit — the exchange argument: show swapping any other choice for the greedy one never helps.

P17 · Intervals

Pattern
What it means

Items are [start, end] ranges on a number line. Sort them, sweep left to right, and handle each new interval against the last one kept — merging, counting, or cutting.

When to recognize it

The input literally consists of ranges — meetings, time slots, segments — and the question is about overlap, coverage, merging, or rooms.

Keywords in the statement

intervals meetings overlap merge rooms schedule time ranges

Typical structure

Sort by start; compare each interval’s start with the last kept interval’s end.

Mental model

Horizontal bars on a timeline. Sorted by start, every overlap question is between the new bar and only the previous bar.

Skeleton
Pseudocode
sort intervals by start
result = []
for each interval [s, e]:
    if result not empty and s <= result.last.end:   # overlap
        result.last.end = max(result.last.end, e)   # extend
    else:
        result.append([s, e])
Python template
Python
def merge_intervals(intervals):
    intervals = sorted(intervals)          # by start
    merged = []
    for start, end in intervals:
        if merged and start <= merged[-1][1]:     # overlaps the last one
            merged[-1][1] = max(merged[-1][1], end)   # extend if needed
        else:
            merged.append([start, end])      # fresh, separate interval
    return merged

print(merge_intervals([[1, 3], [2, 6], [8, 10], [15, 18]]))
# [[1, 6], [8, 10], [15, 18]]
Common mistakes
  • Forgetting to sort — the sweep logic needs start-ordered input.
  • Wrong touch convention: [1,3] and [3,5] overlap for merging but not for “attend all meetings”. Decide what the statement means.
  • Writing merged[-1][1] = end instead of max(...) — breaks when one interval sits fully inside another.
Example problem

Merge overlapping intervals — covered in Part 20 · Interval Problems.

Complexity

Time O(n log n) · Space O(n).

Variations
  • Insert interval — three-phase pass: before / absorbing / after.
  • Meeting rooms — count overlaps via sorted starts vs ends (or a heap of end times).
  • Non-overlapping removals — greedy sort by end (P16 in disguise).

P18 · Basic Bit Manipulation

Pattern
What it means

Treat each integer as a row of on/off switches and operate on the switches: & (both on), | (either), ^ (differ), shifts (multiply / divide by 2). The star trick: XOR cancels pairs, a ^ a = 0.

When to recognize it

“Every element appears twice except one”, power-of-two checks, odd/even, counting set bits — or an O(1) space hint where a set would be the obvious answer.

Keywords in the statement

appears twice except one power of two odd / even binary XOR set bits

Typical structure

One accumulator; one loop combining it with each value using one bitwise operator.

Mental model

XOR is a difference detector: matching switches cancel out, only unpaired switches survive. n & (n-1) deletes the lowest set switch — so a power of two (one switch on) becomes 0.

Skeleton
Pseudocode
result = 0
for each number x:
    result = result XOR x    # pairs cancel, singles survive
return result

power of two:  n > 0 AND (n AND (n-1)) == 0
Python template
Python
def single_number(nums):
    result = 0
    for x in nums:
        result ^= x            # a ^ a == 0, and order never matters
    return result

def is_power_of_two(n):
    # 16 = 10000, 15 = 01111 → 16 & 15 == 0. Only powers of two do that.
    return n > 0 and (n & (n - 1)) == 0

print(single_number([4, 1, 2, 1, 2]))       # 4
print(is_power_of_two(16), is_power_of_two(18))   # True False
Common mistakes
  • Missing the n > 0 guard — 0 & -1 == 0 would wrongly call 0 a power of two.
  • Forgetting XOR needs everything else to appear an even number of times — the trick collapses otherwise.
  • Confusing & (AND) with && (a Python syntax error) and | with ||.
Example problem

Single number — every element appears twice except one — covered in Part 21 · Bit Manipulation Basics.

Complexity

Time O(n) (single number) or O(log n) (bit loops) · Space O(1).

Variations
  • Count set bits — repeatedly do n &= (n - 1); it clears one bit per step.
  • Missing number — XOR all indices and all values; pairs cancel, the missing one survives.
  • Check / set / toggle the i-th bit — masks (1 << i).
Q1. A statement says: “Given a sorted array and a target, return whether two different elements sum to the target.” Best pattern?
Sorted data + a pair condition is the two-pointer signature: each comparison safely discards one candidate (P3).

Which Pattern Should I Use? — the decision tree

Do not read all eighteen cards when you meet a problem. Walk this tree top to bottom; the first question whose answer is YES names your candidate pattern. Then confirm with the keywords in the card.

Decision Tree
START
│
├─ One target value in SORTED data — or a boundary where a condition
│  flips from NO to YES (smallest speed that works, first occurrence)?
│   YES → BINARY SEARCH (P7)
│          └─ searching answers, not the array → binary search on the ANSWER
│
├─ Need O(1) answers to "have I seen this?" or "how many times?"?
│   YES → SEEN SET (P2)  or  FREQUENCY MAP (P1)
│          └─ need counts or "most frequent" → Frequency Map
│          └─ need only existence → Seen Set
│
├─ Looking for a PAIR / TRIPLET meeting a target?
│   ├─ data sorted (or sorting is harmless)  → TWO POINTERS (P3)
│   └─ data unsorted, positions must stay    → hash map of complements (P2)
│
├─ Question about a CONTIGUOUS block (subarray / substring)?
│   ├─ longest / shortest window under a condition   → SLIDING WINDOW (P4)
│   ├─ sum of ANY range, many queries                → PREFIX SUM (P5)
│   ├─ count subarrays with sum exactly k            → PREFIX SUM + hashmap (P5)
│   └─ condition compares values, not sums           → SORT + SCAN (P6) if
│                                                       positions don't matter
│
├─ Nested / matching structure, "most recent unfinished thing"?
│   YES → STACK (P8)
│
├─ Items must be processed strictly in ARRIVAL ORDER?
│   YES → QUEUE (P9)
│
├─ Linked list?
│   ├─ cycle / middle / kth-from-end   → FAST & SLOW POINTERS (P10)
│   └─ restructure nodes (reverse, merge) → careful pointer surgery (P13 part)
│
├─ "Generate ALL ..." (subsets / permutations / combinations / all paths)?
│   YES → BACKTRACKING (P12)
│          └─ one clean self-similar structure, no enumeration → RECURSION (P11)
│
├─ Moving through a NETWORK or GRID?
│   ├─ minimum steps / shortest path (equal costs) → BFS (P13)
│   ├─ explore everything reachable / count regions → DFS (P14)
│   └─ edges have different costs → beyond this book (Dijkstra territory)
│
├─ "K largest / kth / most frequent / k closest / merge k sources"?
│   YES → HEAP of size k (P15)
│
├─ "Minimum NUMBER of ..." / "maximum NUMBER of ..." and a sort
│  creates a sensible order of choices?
│   YES → GREEDY (P16) — then PROVE it (exchange argument + a killer test)
│
├─ Input is a list of [start, end] RANGES?
│   YES → sort by start, sweep → INTERVALS (P17)
│
├─ Clues about binary: "appears twice except one", power of two, parity?
│   YES → BIT MANIPULATION (P18)
│
└─ Nothing fits? Go back to Part 2: restate the problem, squeeze the
   examples, and find the work your brute force repeats.
Important — trees are pruned by constraints

The tree finds candidate patterns; constraints prune them. n ≤ 10⁵ rules out O(n²) branches no matter how well they fit (Part 2, Part 32). A candidate that fits the wording but not the budget is not a solution.

Keyword → Pattern Master Table

The same library, flat. Scan the left column for a phrase from the statement; the middle column names the pattern; the right column jumps to the card.

Keyword phrase in the statementLikely pattern(s)Card
“how many times does X appear”Frequency MapP1
“most frequent element”Frequency Map (+ Heap for top-k)P1, P15
“valid anagram / same letters”Frequency Map, compare countsP1
“first non-repeating character”Frequency Map, two passesP1
“contains duplicate / seen before”Seen SetP2
“two numbers sum to target” (unsorted)Hash map of seen complementsP2
“two numbers sum to target” (sorted)Two PointersP3
“palindrome / reverse / compare ends”Two PointersP3
“remove / move / filter in-place”Two Pointers (read + write)P3
“longest substring without repeats”Sliding WindowP4
“at most k distinct / k occurrences”Sliding Window + counts dictP4
“longest / shortest subarray with condition”Sliding WindowP4
“sum of range i..j, many queries”Prefix SumP5
“count subarrays with sum exactly k”Prefix Sum + hashmapP5
“best so far / running maximum”Prefix max (single pass)P5
“order doesn’t matter / any order”Sorting + ScanP6
“smallest difference between any two”Sort, compare neighborsP6
“sorted array, find X / first occurrence”Binary SearchP7
“minimum speed / capacity / size that works”Binary Search on the answerP7
“valid parentheses / brackets match”StackP8
“next greater element”Stack (monotonic)P8
“undo / backspace / most recent”StackP8
“process tasks in arrival order”QueueP9
“level by level / round robin”Queue (or BFS)P9
“middle of a linked list / cycle”Fast / Slow PointersP10
“kth node from the end”Fast / Slow (gap of k)P10
“nested list / tree / defined in terms of itself”RecursionP11
“generate all subsets / permutations”BacktrackingP12
“all combinations that sum to target”Backtracking + pruningP12
“minimum steps / shortest path (unweighted)”BFSP13
“level order traversal”BFS with per-level sizingP13
“count islands / connected components”DFS (or BFS)P14
“all reachable nodes / any path exists”DFSP14
“kth largest / kth smallest”Heap of size kP15
“top k frequent / k closest”Frequency Map + HeapP15
“merge k sorted lists”Heap of front-runnersP15
“minimum number of coins / rooms / jumps”Greedy (verify it!)P16
“maximum number of non-overlapping X”Greedy, sort by endP16
“merge overlapping intervals”Intervals (sort + sweep)P17
“minimum meeting rooms / can attend all”Intervals (starts vs ends)P17
“every element appears twice except one”XOR trickP18
“power of two / odd / even / set bits”Bit ManipulationP18
“missing number, O(1) space”XOR (or sum formula)P18

Phrases overlap and real statements blend two patterns (Frequency Map + Heap, Prefix Sum + Seen Set). The table names the first pattern to try — the decision tree above resolves ties.

Q2. “Return the number of contiguous subarrays that sum exactly to k.” Numbers can be negative. Pattern?
Negative numbers break the window’s “grow = bigger sum” logic. Prefix sums make every subarray sum a subtraction, and a hashmap counts matching prefixes (P4 vs P5).

What You Learned

  • Eighteen patterns cover the vast majority of beginner problems — each card gives its keywords, skeleton, template, mistakes, and variations.
  • Pattern choice is optimization: the pattern you pick names the repeated work you eliminate (Two Sum: O(n²) → O(n log n) → O(n)).
  • The decision tree walks from question to candidate pattern; the keyword table is the flat lookup. Both end at the same cards.
  • Constraints prune the tree: a pattern that fits the words but not the time budget is not a candidate.
  • Drill the skeletons, not the solutions — every template above starts from one.

Pattern Recognition

This chapter is the recognition map. Under time pressure: keyword table first (10 seconds), decision tree second (1 minute), card third. If nothing matches, the honest next move is a brute force plus the five questions of Part 33 — not a random pattern forced onto the problem.

Complexity Summary

PatternTypical timeTypical space
P1 Frequency MapO(n)O(k) distinct items
P2 Seen SetO(n)O(n)
P3 Two PointersO(n) (+ sort if needed)O(1)
P4 Sliding WindowO(n)O(k)
P5 Prefix SumO(n) build, O(1)/queryO(n)
P6 Sorting + ScanO(n log n)O(1)–O(n)
P7 Binary SearchO(log n)O(1)
P8 StackO(n) totalO(n)
P9 QueueO(n) totalO(n)
P10 Fast/Slow PointerO(n)O(1)
P11 RecursionO(calls)O(depth) stack
P12 BacktrackingO(2ⁿ)–O(n · n!)O(n) + output
P13 BFSO(V + E)O(V)
P14 DFSO(V + E)O(V) + depth
P15 Heap / Top-KO(n log k)O(k)
P16 GreedyO(n log n)O(1)–O(n)
P17 IntervalsO(n log n)O(n)
P18 Bit ManipulationO(n) or O(log n)O(1)

Common Mistakes

  • Forcing your favorite pattern onto every problem instead of reading the keywords.
  • Skipping the constraint check — nailing the pattern but blowing the time budget.
  • Using a sliding window with negative numbers, or two pointers on unsorted data.
  • Trusting a greedy without the exchange argument and a small killer test.
  • Exploring a graph without a visited set — BFS or DFS, the loop is the same bug.

Mini Quiz

Q1. “Every number appears twice, except one which appears once — solve in O(1) extra space.” Pattern?
A set or map needs O(n) space. XOR cancels each pair, leaving the single number — O(n) time, O(1) space (P18).
Q2. “Return the middle node of a linked list in one pass with O(1) space.” Pattern?
When fast reaches the end, slow has travelled half the distance — the middle (P10).
Q3. The first step of merge-intervals is…
Sorted by start, each interval only needs to be compared with the last merged one (P17).
Q4. “Find whether any continuous stretch of days has an average above x.” The array has negative temperatures. What do you reach for first?
“Contiguous” suggests a window, but negative values break its shrink logic — prefix sums handle any values (P4 vs P5).
Q5. You need the 5 smallest of one million live-updating sensor readings. Best approach?
A size-k max-heap keeps the k smallest; its root is the boundary each new reading must beat — O(log k) per update (P15).

Practice Problems

Practice 1 — First Recurring Character

🟢 L1
Problem

Return the first character in a string that appears a second time, scanning left to right — or None. Name the pattern before coding.

Show Hint

You only need existence, not counts — which card is lighter than a frequency map?

Show Approach

One pass with a seen set: ask, then add (P2). The first character already in the set is the answer.

Show Solution
Python
def first_recurring(s):
    seen = set()
    for ch in s:
        if ch in seen:
            return ch
        seen.add(ch)
    return None

print(first_recurring("abba"))    # 'b'
print(first_recurring("abcdef"))  # None

Practice 2 — Count Subarrays Summing to k

🟡 L2
Problem

nums = [1, 1, 1], k = 2 → 2 (subarrays [1,1] at indices 0-1 and 1-2). Numbers may be negative. Name the pattern before coding.

Show Hint

What does each subarray sum become if you subtract two prefix sums? What have you seen before at each position?

Show Approach

Keep a running total and a hashmap prefix_sum → how many times seen. At each step add counts[total - k] to the answer (P5).

Show Solution
Python
def count_subarrays_sum_k(nums, k):
    counts = {0: 1}          # empty prefix has sum 0
    total = 0
    answer = 0
    for x in nums:
        total += x
        answer += counts.get(total - k, 0)   # earlier prefixes that complete k
        counts[total] = counts.get(total, 0) + 1
    return answer

print(count_subarrays_sum_k([1, 1, 1], 2))    # 2
print(count_subarrays_sum_k([1, -1, 1], 1))   # 3

Practice 3 — Kth Smallest Element

🟡 L2
Problem

[7, 10, 4, 3, 20, 15], k = 3 → 7. Use a heap of size k, and remember what Python’s heapq gives you by default.

Show Hint

For kth smallest, the boundary element is the largest of the k smallest. What heap does heapq not have, and what is the classic fix?

Show Approach

Negate values in and out: a min-heap of negatives behaves as a max-heap of originals. Evict the largest whenever the heap outgrows k (P15).

Show Solution
Python
import heapq

def kth_smallest(nums, k):
    heap = []                    # min-heap of negatives = max-heap of values
    for x in nums:
        heapq.heappush(heap, -x)
        if len(heap) > k:
            heapq.heappop(heap)  # evict the LARGEST of the k smallest
    return -heap[0]

print(kth_smallest([7, 10, 4, 3, 20, 15], 3))   # 7

Challenge Problem

Challenge — Group the Anagrams

🟠 L3
Problem

Given a list of words, group words that are anagrams of each other: ["eat", "tea", "tan", "ate", "nat", "bat"] → [["eat","tea","ate"], ["tan","nat"], ["bat"]]. It composes two patterns from this chapter.

Show Hint

What single value is identical for every anagram? Could that value be a dictionary key?

Show Approach

Pattern composition: sorting + scan (P6) produces a canonical signature "".join(sorted(word)), and a grouping map signature → list of words (P1’s variation) collects the groups in one pass.

Show Solution
Python
def group_anagrams(words):
    groups = {}
    for w in words:
        key = tuple(sorted(w))     # anagrams share one sorted signature
        groups.setdefault(key, []).append(w)
    return list(groups.values())

print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
# [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]

Time O(n · m log m) for n words of length m; the signature is the repeated work that disappeared — no word is compared with any other word.

Revision Checklist

PART 24

Brute Force → Better → Optimal

Optimization is not inspiration. It is a process: find the repeated work, then kill it.

Nobody looks at Two Sum for the first time and "just sees" the O(n) hash map solution. They write the O(n²) double loop first — and that is exactly right. The brute force is not a failure state; it is a map of the repeated work. Every optimization in this part was found the same way: write the honest slow version, stare at what it computes twice, and replace that repetition with memory or movement.

We will walk three classic problems through all three stages, and after each one answer the question that matters most: what repeated work disappeared?

Write brute force
↓
Name the repeated work
↓
Remember it / move pointers / reuse sums
↓
Optimal

24.1 Two Sum — from rescanning to remembering

24.1 Two Sum

🟡 L2 · Apply
Problem

Given a list of integers and a target, return the indices of the two numbers that add up to the target. Exactly one solution exists.

Input / Output

nums = [2, 7, 11, 15], target = 9 → [0, 1] (2 + 7 = 9)

Brute Force — O(n²)
Python
def two_sum_brute(nums, target):
    n = len(nums)
    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return [i, j]
    return []

print(two_sum_brute([2, 7, 11, 15], 9))   # [0, 1]

Correct and a perfectly good first draft. Now the diagnostic question: what does the inner loop keep doing? For every i it re-scans the rest of the array asking one question: "does target - nums[i] exist here?" That is a search, repeated n times, and each search is O(n).

The Better Idea — stop re-searching, start remembering

A search for a value that you perform over and over is the classic signal for a hash map (fast lookup, as in the arrays part). Walk the list once. For each x, before anything else, ask the map: "have I already seen target - x?" If yes, done. If no, remember x and move on.

Optimal Idea

One pass, dictionary of value → index, complement checked before inserting the current element (so an element can never pair with itself).

Python Solution
Python
def two_sum(nums, target):
    seen = {}                        # value -> index
    for i, x in enumerate(nums):
        need = target - x
        if need in seen:             # O(1) instead of an O(n) rescan
            return [seen[need], i]
        seen[x] = i
    return []

print(two_sum([2, 7, 11, 15], 9))   # [0, 1]
Dry Run
Dry Run — nums = [2, 7, 11, 15], target = 9
i=0, x=2:  need = 7   → 7 not in seen (empty) → seen = {2: 0}
i=1, x=7:  need = 2   → 2 IS in seen (index 0)  → return [0, 1] ✅
Edge check — nums = [3, 3], target = 6 (duplicate answer)
i=0, x=3:  need = 3   → not in seen yet → seen = {3: 0}
i=1, x=3:  need = 3   → found at index 0 → return [0, 1] ✅
Because we check BEFORE inserting, the first 3 is never paired with itself.
Complexity

Brute: O(n²) time, O(1) space · Optimal: O(n) time, O(n) space for the map

What repeated work disappeared?
Answer

The inner-loop re-scan for the complement. The map remembers every value already visited, turning an O(n) question into an O(1) lookup. n searches of length n became n searches of length 1.

Pattern Recognition
Pattern Insight

"For each element I look something up in the rest of the array" → the lookup is the repeated work → hash map. Trade O(n) memory for the whole inner loop.

O(n²) all pairs
→
repeated work: search for complement
→
hash map remembers
→
O(n) one pass
one lookup per element replaces one full scan per element
Q1. In Two Sum, why check need in seen before inserting x?
With target = 8 and x = 4, inserting first would find your own 4 and return the same index twice. Checking first guarantees two different positions.

24.2 Max subarray sum — from recomputing overlaps to one running number

24.2 Maximum Subarray Sum (Kadane)

🟠 L3 · Recognize
Problem

Find the largest possible sum of a contiguous run of elements. [-2, 1, -3, 4, -1, 2, 1, -5, 4] → 6 (the run 4, -1, 2, 1).

Brute Force — O(n²) with a running sum

Try every start i, extend the end j, and keep the sum as you extend (this is already one level smarter than re-summing each window from scratch):

Python
def max_subarray_brute(nums):
    best = nums[0]
    for i in range(len(nums)):
        curr = 0
        for j in range(i, len(nums)):
            curr += nums[j]          # sum of nums[i..j], extended by 1
            best = max(best, curr)
    return best

print(max_subarray_brute([-2, 1, -3, 4, -1, 2, 1, -5, 4]))   # 6
Observe the repeated work

Look at what the loops compute for i = 3: sums 4, 3, 5, 6, 1, 5. For i = 4: sums -1, 1, 2, -3, 3. The windows overlap heavily — each start re-treads ground the previous start already covered. Roughly n²/2 window sums are produced, but they are all built from the same few running totals.

Better — the prefix-sum view

Sum of window (i..j) = P[j+1] - P[i], where P is the running total from the left (P[0] = 0). So "best window ending at j" = P[j+1] - (smallest P[i] seen at or before i = j). If we track the minimum prefix so far while scanning once, every position is decided in O(1):

Python
def max_subarray_prefix(nums):
    best = nums[0]
    prefix = 0
    min_prefix = 0              # smallest P[i] so far (P[0] = 0)
    for x in nums:
        prefix += x             # this is P[j+1]
        best = max(best, prefix - min_prefix)
        min_prefix = min(min_prefix, prefix)
    return best

This is already O(n). But notice what the quantity prefix - min_prefix really is: the best subarray ending exactly here. Kadane's algorithm is the same idea with the subtraction hidden inside two plain variables — easier to write, easier to trust.

Optimal — Kadane

Scan once, carrying curr = best sum of a window ending at the current element. Either extend the previous window or throw it away and restart at x — restart precisely when the carried sum would only drag x down (curr < 0 before adding).

Python
def max_subarray_kadane(nums):
    best = nums[0]
    curr = nums[0]
    for x in nums[1:]:
        curr = max(x, curr + x)   # extend the run, or restart at x
        best = max(best, curr)
    return best

print(max_subarray_kadane([-2, 1, -3, 4, -1, 2, 1, -5, 4]))   # 6
Dry Run
Dry Run — nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
start:      curr = -2, best = -2
x =  1:  curr = max(1, -2 + 1) = 1        (restart!)  best = 1
x = -3:  curr = max(-3, 1 - 3)  = -2                  best = 1
x =  4:  curr = max(4, -2 + 4) = 4        (restart!)  best = 4
x = -1:  curr = max(-1, 4 - 1)  = 3                   best = 4
x =  2:  curr = max(2, 3 + 2)   = 5                   best = 5
x =  1:  curr = max(1, 5 + 1)   = 6                   best = 6  ← run 4,-1,2,1
x = -5:  curr = max(-5, 6 - 5)  = 1                   best = 6
x =  4:  curr = max(4, 1 + 4)   = 5                   best = 6
→ return 6 ✅
Complexity

Brute: O(n²) time, O(1) space · Prefix / Kadane: O(n) time, O(1) space

What repeated work disappeared?
Answer

Recomputing sums of overlapping windows. The brute force builds ~n²/2 window sums; Kadane keeps a single running window sum and a best-so-far, so each element is touched once. The prefix view explains why that is enough: every window sum is a difference of two prefix totals, and only the smallest prefix so far can matter.

Edge Cases
  • All negative, e.g. [-3, -1, -7] → -1. Works because best starts at nums[0], not 0 (a classic bug — see the Edge Cases part).
  • Single element → that element.
  • All positive → the whole array (curr never wants to restart).
Pattern Recognition
Pattern Insight

"Best sum/value of a contiguous window" + brute force recomputes overlaps → carry one running window value and restart it when it stops helping. If the brute force re-derives information from earlier positions, ask what single number could summarize it.

O(n²) all windows
→
repeated work: overlapping sums
→
prefix = window algebra
→
O(n) one running sum
n²/2 window sums collapse into one curr that never looks back
Q2. Kadane restarts the window (curr = x) exactly when...
max(x, curr + x) picks x precisely when curr + x < x, i.e. when curr < 0. A negative carried sum can never help the future.

24.3 Pair with given sum in a sorted array — from searching to pointing

24.3 Pair with Given Sum (sorted input)

🟡 L2 · Apply
Problem

Given a sorted (ascending) list, decide whether two elements add up to target, and return their indices.

Input / Output

nums = [1, 3, 4, 6, 8, 10, 13], target = 13 → [1, 5] (3 + 10)

Brute Force — O(n²)

Same double loop as Two Sum. It works — but it ignores the word sorted entirely, which should feel suspicious.

Better — sort gives you binary search

For each element x, the partner we need is exactly target - x. In a sorted array, "does this value exist to the right of i?" is a binary search — O(log n) instead of O(n) (see the Binary Search part):

Python
from bisect import bisect_left

def pair_sorted_search(nums, target):
    n = len(nums)
    for i in range(n):
        need = target - nums[i]
        j = bisect_left(nums, need, i + 1)   # binary search, right of i only
        if j < n and nums[j] == need:
            return [i, j]
    return []

O(n log n). The repeated inner scan became a search over a shrinking structure. But we can go further: if two pointers walk toward each other, the comparisons themselves tell us where the answer can and cannot live.

Optimal — two pointers

Start with left at the smallest and right at the largest value. If the sum is too small, the only way to grow it is to advance left; if too big, only retreating right can shrink it. Every step retires one element for good.

Python
def pair_sorted_two_pointers(nums, target):
    left, right = 0, len(nums) - 1
    while left < right:
        s = nums[left] + nums[right]
        if s == target:
            return [left, right]
        if s < target:
            left += 1      # sum too small: only a bigger left value helps
        else:
            right -= 1     # sum too big: only a smaller right value helps
    return []

print(pair_sorted_two_pointers([1, 3, 4, 6, 8, 10, 13], 13))   # [1, 5]
Dry Run
Dry Run — nums = [1, 3, 4, 6, 8, 10, 13], target = 13
Step 1: left=0 (1),  right=6 (13) → 1 + 13 = 14 > 13  → right -= 1
Step 2: left=0 (1),  right=5 (10) → 1 + 10 = 11 < 13  → left += 1
Step 3: left=1 (3),  right=5 (10) → 3 + 10 = 13 ✅    → return [1, 5]
10L
31
42
63
84
105
136R
step 1: 1 + 13 = 14 too big → drop the 13 (no partner for it left) → move right
Why the movement is safe

When nums[left] + nums[right] > target, then nums[right] is too big for every remaining partner — all candidates for left are even smaller. So right can never form a pair again and is discarded safely. Symmetrically for the too-small case. Nothing that could have been an answer is ever thrown away.

Complexity

Brute: O(n²) · Sort+binary search: O(n log n) (the array is already sorted, so no sort cost) · Two pointers: O(n) time, O(1) space

What repeated work disappeared?
Answer

Re-checking pairs that one comparison could already rule out. The brute force tests every pair; two pointers test at most n sums, because each step eliminates an entire row or column of the i-j pair table. The sortedness is what makes the elimination logic legal.

Pattern Recognition
Pattern Insight

Sorted input + "find a pair / triplet with some sum property" → two pointers moving inward. If the input is not sorted and order of indices does not matter, sorting first is usually worth it.

O(n²) all pairs
→
sortedness = searchability
→
binary search per element
→
O(n) two pointers
each pointer step permanently retires one element from the search
Q3. In two pointers, the sum is too small. Why is it safe to move left instead of right?
The array is sorted, so advancing left only increases the sum. Retreating right would decrease it — the wrong direction. And nums[right] is now proven partnerless.

The recipe — optimization as a process

The 4-step upgrade recipe
1

Write the brute force honestly. It defines correctness and shows you the shape of the work. Skipping this step is how people get stuck "trying to think of something clever".

2

Name the repeated work in one precise sentence. "I re-scan the array for each element." "I recompute sums of overlapping windows." "I re-test pairs one comparison could rule out." If you cannot name it, you cannot remove it.

3

Pick the disappearance pattern:

  • Remember it — hash map / set / prefix sums (kills repeated searching or summing).
  • Reorganize it — sort, then binary search or two pointers (makes structure visible).
  • Reuse it — running sums, min/max so far (never re-derive from scratch).
4

Pay the bill and verify. Extra space? Lost original order (sort)? New invariants (pointers)? Then re-test on a 2-element input and one edge case — optimizations introduce their own bugs.

Mini exercise — label the stages yourself

Problem: "Contains duplicate?" — given a list, return True if any value appears at least twice. Below are three anonymous implementations. Before opening each reveal, decide: which stage is it (Brute / Better / Optimal), and what repeated work does it remove compared to the one before it?

Python
# Snippet A
def has_dup_a(nums):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] == nums[j]:
                return True
    return False

# Snippet B
def has_dup_b(nums):
    s = sorted(nums)
    for i in range(1, len(s)):
        if s[i] == s[i - 1]:
            return True
    return False

# Snippet C
def has_dup_c(nums):
    return len(set(nums)) < len(nums)
  • A = Brute Force, O(n²) — compares every pair.
  • B = Better, O(n log n) — sorting places equal values side by side, so only adjacent pairs need checking.
  • C = Optimal, O(n) — a set remembers every value; a duplicate makes the set smaller than the list.
  • A → B: disappeared the all-pairs comparisons — after sorting, a duplicate must sit next to itself, so n² comparisons collapse into n - 1 adjacent checks.
  • B → C: disappeared even the sorting pass — the set remembers values in O(1) per insert, so no reorganization is needed at all.
Q4. For nums of size 100,000, which snippet finishes in well under a second?
A does ~n²/2 = 5 · 10^9 comparisons. B and C are both fine; C is the optimal single pass with O(n) memory.

What You Learned

  • The upgrade path is a process: brute force → name the repeated work → remember / reorganize / reuse → verify.
  • Two Sum: repeated searching disappears into a hash map (O(n²) → O(n)).
  • Max subarray: repeated overlap re-summing disappears into one running window (O(n²) → O(n), prefix-sum logic underneath).
  • Sorted pair sum: repeated pair re-checking disappears into binary search, then two pointers (O(n²) → O(n log n) → O(n)).
  • Every optimization has a bill: extra memory, lost order, or a new invariant you must maintain.

Pattern Recognition

Say the repeated work out loud and match it to its killer: repeated lookup → hash map; repeated sum over ranges → running/prefix sums; repeated pairwise testing on unordered data → sort or set; repeated testing on sorted data → binary search or two pointers. If the brute force touches each element's information more than once, something remembers or points instead.

Complexity Summary

ProblemBruteBetterOptimalWhat disappeared
Two SumO(n²)—O(n) hash mapcomplement re-scans
Max subarray sumO(n²)O(n) prefix viewO(n) Kadaneoverlapping window sums
Sorted pair sumO(n²)O(n log n) binary searchO(n) two pointersunruled-out pair checks
Contains duplicateO(n²)O(n log n) sort+scanO(n) setall-pairs / sorting

Common Mistakes

  • Skipping the brute force and "thinking" at the whiteboard — you lose both your correctness baseline and your map of the repeated work.
  • Optimizing without naming the repetition first; the "clever" version then hides a bug you cannot see.
  • Forgetting the bill: a hash map costs O(n) memory, sorting loses original indices, two pointers require the sorted order to be real.
  • Inserting into the map before checking the complement (self-pairing bug in Two Sum).
  • Initializing best = 0 in max-subarray code — fails on all-negative arrays.

Mini Quiz

Q1. What is the first practical purpose of a brute force solution?
It is the correctness oracle and the map: whatever it computes twice (or n times) is your optimization target.
Q2. "For each element I search the rest of the array" points to which fix?
Repeated value-lookups are exactly what a dictionary/set reduces to O(1).
Q3. In the sorted pair-sum problem, which fact makes two pointers legal?
The elimination argument ("too big → no partner left for right") only holds because values are ordered.
Q4. Put the recipe in order.
The brute force comes first (correctness + map), the repetition is named second, only then do you choose the structure that kills it.

Practice Problems

Practice 1 — Max sum of k consecutive elements

🟡 L2

Given nums and k, find the largest sum of any k consecutive elements. [1, 4, 2, 9, 3], k = 2 → 12. Label the stages, then solve.

Show Hint

The brute force re-sums each window from scratch: O(n·k). What does sliding one position actually change about the sum?

Show Approach

Compute the first window's sum, then for each slide: subtract the element leaving, add the element entering. One pass, O(n).

Show Solution
Python
def max_k_sum(nums, k):
    window = sum(nums[:k])
    best = window
    for i in range(k, len(nums)):
        window += nums[i] - nums[i - k]   # slide: + entering, - leaving
        best = max(best, window)
    return best

print(max_k_sum([1, 4, 2, 9, 3], 2))   # 12  (9 + 3)

Repeated work that disappeared: re-adding all k elements for every window.

Practice 2 — First non-repeating character

🟡 L2

Return the index of the first character that appears exactly once in s, or -1. "lovelyleetcode" → 2 ('v').

Show Hint

The brute force counts, for each character, how often it appears by re-scanning the string: O(n²). Which bookkeeping removes the re-scan?

Show Approach

One pass builds a frequency dictionary; a second pass in original order finds the first char with count 1. O(n) total.

Show Solution
Python
def first_unique(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    for i, ch in enumerate(s):
        if counts[ch] == 1:
            return i
    return -1

print(first_unique("lovelyleetcode"))   # 2

Practice 3 — Pair with difference k (unsorted)

🟡 L2

Given an unsorted list and k > 0, decide whether two different elements differ by exactly k. [5, 1, 9, 3], k = 4 → True (5 and 1, or 5 and 9).

Show Hint

Brute force compares all pairs: O(n²). For each x, what single question about the past would settle it?

Show Approach

Set version: scan once; for x ask "is x - k or x + k already in the set?", then add x. O(n).

Show Solution
Python
def has_diff_k(nums, k):
    seen = set()
    for x in nums:
        if (x - k) in seen or (x + k) in seen:
            return True
        seen.add(x)
    return False

print(has_diff_k([5, 1, 9, 3], 4))   # True

Challenge Problem

Challenge — Maximum average of a size-k window

🟠 L3 · Recognize

Given nums and k, return the maximum average of any k consecutive elements as a float. [1, 12, -5, -6, 50, 3], k = 4 → 12.75 (window 12, -5, -6, 50 → sum 51). Write the brute force, then the O(n) version, and state what repeated work disappeared.

Show Hint

Maximizing the average of fixed-size windows = maximizing their sum. You have already met this problem in Practice 1 — the only new stage is the brute force.

Show Solution
Python
# Stage 1 — Brute: O(n·k), re-sums every window
def max_avg_brute(nums, k):
    best = None
    for i in range(len(nums) - k + 1):
        s = sum(nums[i:i + k])
        if best is None or s > best:
            best = s
    return best / k

# Stage 2 — Optimal: sliding window, O(n)
def max_avg(nums, k):
    window = sum(nums[:k])
    best = window
    for i in range(k, len(nums)):
        window += nums[i] - nums[i - k]
        best = max(best, window)
    return best / k

print(max_avg_brute([1, 12, -5, -6, 50, 3], 4))   # 12.75
print(max_avg([1, 12, -5, -6, 50, 3], 4))         # 12.75

What disappeared: the inner re-sum of every window (n·k additions → 2 additions per slide). Dividing by k once at the end gives the average.

Revision Checklist

PART 25

Edge Cases

Code that passes the main test can still be wrong. The bugs hide at the edges — and they hide predictably.

There is a pattern to broken submissions: the algorithm is right, the main example passes, and then one peculiar input — an empty list, a negative number, two identical values — exposes a crack. The good news: that peculiarity is not random. There are roughly a dozen kinds of edge cases, they appear again and again, and you can learn to run through them deliberately before submitting anything.

The edge-case catalogue — 14 kinds that break real code

Each card below shows a concrete mini example and what typically breaks. As you read, try to recall a bug you personally hit for each one.

1 · Empty input

Example: []. Loops never run (you silently return an initial value), while nums[0], max(nums) and nums[1:]-based code crash. Decide on purpose: return None, [], 0, or raise — and make it a decision, not an accident.

2 · One element

Example: [7]. Is the single element the answer? "Second largest" is undefined. Loops like range(1, n) do zero iterations. Many two-pointer and window solutions have no valid move here.

3 · Two elements

Example: [1, 2]. The smallest interesting comparison. Pointer problems hit their left < right boundary immediately, and division-based logic (midpoints) rounds in surprising ways: mid = 0.

4 · Duplicates

Example: [4, 4, 4, 1]. "First occurrence" logic, strict vs non-strict comparisons (> vs >=), and dictionary writes that overwrite earlier entries all bend here.

5 · All same values

Example: [5, 5, 5]. Partitions never split, min == max, the distinct count is 1. Algorithms that assume "the pivot divides something" can loop forever or return nonsense.

6 · Negative values

Example: [-3, -1, -7]. Accumulators initialized to 0 (best = 0) silently win. So do assumptions like "bigger number = later answer" or "the product of two elements is positive".

7 · Zero

Example: [0, 0], or n = 0 as a value. Division by zero, while n != 0 never entering, and products that flatten everything to 0 are the usual wounds.

8 · Already sorted

Example: [1, 2, 3, 4]. Best-case timing for some algorithms — and the worst enemy of sloppy binary-search boundaries: the missing element is often the first or last one.

9 · Reverse sorted

Example: [9, 5, 1]. The mirror of #8: worst cases surface here (some scans do maximal work), and pair-sum pointers walk the entire array before failing to find a pair.

10 · Very large input

Example: n = 1,000,000. An O(n²) double loop becomes minutes, not milliseconds. Recursion depth can hit Python's ~1000 limit. Check constraints before choosing an algorithm.

11 · Sizes that do not fit

Example: k = 10 but len(nums) = 3. Windows bigger than the array, targets smaller than every element, nums[i + k] past the end. The sizes in the statement usually have unstated relationships — test the violations.

12 · Missing values

Example: search for 8 in [1, 3, 5]. Nothing is found. What then — -1? None? an exception? The return contract must say, and your code must say the same thing on every not-found path.

13 · Repeated updates to bookkeeping

Example: seen[x] = i on [7, 7] — the dict keeps the last index. Counters drift by one, sets lose duplicates, "first" indexes quietly become "last". Re-check every write to shared state.

14 · Boundary positions

Example: index 0, index n - 1, i == j, lo == hi. Off-by-one bugs live exactly here. Test the first step, the last step, and the moment two pointers or indices meet.

Q1. best = 0 as the initial value of a max-subarray solver fails on which category?
With all-negative values no subarray reaches 0, but the accumulator starts there and "wins". Seed accumulators from the data, not from wishful constants.
Q2. A two-pointer pair search receives [5]. What should happen?
One element → zero iterations → the not-found return happens naturally. Just make sure that return path returns what the contract promises (not a stale variable).

The pre-submit edge-case checklist

Before you submit anything, run this list against your code. It takes two minutes and catches the majority of wrong-answer verdicts. The boxes persist, so you can use it as a ritual for each practice problem.

Where edge cases come from

Staring at code rarely reveals edges. Sources do:

1

The constraints. "1 <= n <= 10^5" quietly promises n = 1 exists and forbids n = 0. "Values up to 10^9" forbids summing 10^5 of them naively in overflow-prone languages (and warns about slow arithmetic).

2

The value range. Can values be 0? Negative? Equal to each other? Every "yes" unlocks one of the catalogue cards above.

3

The structure. Sorted? Reverse sorted? All equal? Two elements? Try the extreme shapes of the input, not just a random-looking one.

4

Your own loop boundaries. Walk the first iteration and the last iteration by hand — that is where category 14 bites.

Bug stories — code that passed the main test

Three real (and very common) failures. For each: the buggy code, the input that kills it, the trace of the kill, and the fix. Try to spot the bug before opening the Trace tab.

Story 1 — second largest that lies when values repeat

Python
def second_largest(nums):
    best = float('-inf')
    second = float('-inf')
    for x in nums:
        if x > best:
            second = best
            best = x
        elif x > second:
            second = x
    return second

print(second_largest([4, 9, 2, 7]))   # 7  ✅ the main test passes
print(second_largest([3, 3]))         # 3  ❌ there is no second distinct value

[3, 3] — duplicates (catalogue #4 and #5). Expected: None (only one distinct value exists). The code happily returns 3 — the largest value posing as its own runner-up.

Trace — second_largest([3, 3])
start:   best = -inf, second = -inf
x = 3:   3 > -inf  → second = -inf, best = 3
x = 3:   3 > 3 is False → fall to elif
         elif: 3 > -inf → second = 3        ← the duplicate slipped in
return 3  ❌

The elif accepts a value equal to best. "Second largest" should mean second distinct value — the condition never says so.

Python
def second_largest(nums):
    best = float('-inf')
    second = float('-inf')
    for x in nums:
        if x > best:
            second = best
            best = x
        elif best > x > second:   # strictly between: rejects duplicates of best
            second = x
    return second if second != float('-inf') else None

print(second_largest([4, 9, 2, 7]))   # 7   ✅ still correct
print(second_largest([3, 3]))         # None ✅ honest now
print(second_largest([5, 5, 4]))      # 4   ✅ duplicate + distinct mix

The chained comparison best > x > second is the entire fix: a value equal to best is no longer "between".

Story 2 — max subarray that cannot lose

Python
def max_subarray(nums):
    best = 0                 # ← looks harmless
    curr = 0
    for x in nums:
        curr = max(x, curr + x)
        best = max(best, curr)
    return best

print(max_subarray([2, -1, 3]))     # 4  ✅ main test passes
print(max_subarray([-3, -1, -7]))   # 0  ❌ the answer must be -1

[-3, -1, -7] — negative values (catalogue #6). Every subarray has a negative sum; the true maximum is -1 (the window [-1]). The code returns 0 — the sum of the empty window, which the problem does not allow.

Trace — max_subarray([-3, -1, -7])
start:    curr = 0,  best = 0
x = -3:   curr = max(-3, 0 - 3) = -3;  best = max(0, -3) = 0
x = -1:   curr = max(-1, -3 - 1) = -1; best = max(0, -1) = 0
x = -7:   curr = max(-7, -1 - 7) = -7; best = max(0, -7) = 0
return 0  ❌ — 0 was never a real window sum; it just started there and "won"
Python
def max_subarray(nums):
    best = nums[0]           # seed from the data, not from 0
    curr = nums[0]
    for x in nums[1:]:
        curr = max(x, curr + x)
        best = max(best, curr)
    return best

print(max_subarray([-3, -1, -7]))   # -1 ✅
print(max_subarray([2, -1, 3]))     # 4  ✅ main test still passes

Seed accumulators from the input (or from float('-inf')) whenever "the answer could be anything", and never let a constant beat every real candidate.

Story 3 — the function that never met an empty list

Python
def find_max(nums):
    best = nums[0]            # ← assumes there IS a first element
    for x in nums[1:]:
        if x > best:
            best = x
    return best

print(find_max([3, 1, 2]))   # 3  ✅
print(find_max([]))          # IndexError: list index out of range ❌

[] — empty input (catalogue #1). Nothing in the logic is wrong; the assumption "there is at least one element" was never checked and never documented.

Trace — find_max([])
line 2:  best = nums[0]
         nums is empty → there is no index 0
         IndexError: list index out of range  ❌ (crash, not an answer)

Note the subtlety: a version using best = float('-inf') would not crash — it would return -inf, which is arguably worse: a silent, meaningless answer.

Python
def find_max(nums):
    if not nums:              # empty input is a decision, not a surprise
        return None           # documented: "no elements, no maximum"
    best = nums[0]
    for x in nums[1:]:
        if x > best:
            best = x
    return best

print(find_max([3, 1, 2]))   # 3
print(find_max([]))          # None — deliberate and documented

Whatever you choose (None, an exception, a sentinel), the important part is that the empty case is chosen once and behaves the same on every not-found path.

Q3. Story 2's bug (best = 0) and Story 1's bug share a root cause. Which?
0 as a "starting best" wins over all-negative data; a non-strict elif lets a duplicate impersonate the runner-up. Sentinels and comparison boundaries must match the problem's real value set.

What You Learned

  • Fourteen recurring edge-case kinds: empty, one, two, duplicates, all-same, negatives, zero, sorted, reverse-sorted, huge n, mismatched sizes, missing values, bookkeeping overwrites, boundary positions.
  • A two-minute pre-submit checklist you can run on every solution.
  • Where edge cases come from: constraints, value ranges, input structure, and your own loop boundaries.
  • How real bugs die: reproduce with a minimal failing input, trace it, then fix the assumption — not just the symptom.

Pattern Recognition

"Passes the example, fails the judge" → run the checklist; the failing category is usually one of the fourteen. "Works for positive, breaks somewhere" → negatives or zero. "Works sometimes, wrong on resubmit" → duplicates or bookkeeping overwrites. "Crashes only occasionally" → empty or size-mismatched inputs. Naming the category is half the fix.

Complexity Summary

SkillCostPayoff
Running the 10-item checklist before submitting~2 minutescatches most wrong-answer verdicts
Testing on [] and [x] and [x, y]secondskills categories 1-3 and most crashes
Negatives + zero test inputsecondskills accumulator initialization bugs
Minimal failing input (shrink it)minutesturns a mystery into a 3-line trace

Common Mistakes

  • Treating edge cases as bad luck instead of a fixed catalogue you can enumerate.
  • "Fixing" a crash by catching the exception and returning a random value — decide the contract instead.
  • Seeding max-accumulators with 0 (or min-accumulators with a huge positive) when inputs can be negative.
  • Testing only the provided example and submitting.
  • Fixing the symptom (one input) without asking which category it came from.

Mini Quiz

Q1. Which pair of inputs is the smallest set worth testing for almost any array function?
Empty and size-1 expose uninitialized assumptions, loop ranges that never run, and index errors — in seconds.
Q2. seen[x] = i executed twice for the same x (input [7, 7]) leaves...
Dict writes overwrite. If you need the first occurrence, check x in seen before writing, or store only what is missing.
Q3. "k = 10, len(nums) = 3" belongs to which catalogue category?
Window/step sizes larger than the input — check what your slicing and indexing do when the sizes violate their unstated relationship.
Q4. In Story 1, the one-token fix was changing elif x > second to...
The runner-up must be strictly smaller than the best value — otherwise duplicates of the maximum pose as "second".

Practice Problems

Practice 1 — Harden the "leaders" scan

🟢 L1

An element is a leader if it is strictly greater than everything to its right. A friend's solution crashes or misbehaves on [] and [5]. Write the hardened version (return [] for empty).

Show Hint

Scan from the right keeping the max so far; the empty and single cases must fall out naturally or be guarded explicitly.

Show Approach

Guard if not nums: return [], then walk from the last index: any element > max-so-far is a leader; update max-so-far.

Show Solution
Python
def leaders(nums):
    if not nums:
        return []
    result = []
    max_so_far = float('-inf')
    for x in reversed(nums):
        if x > max_so_far:
            result.append(x)
            max_so_far = x
    return result[::-1]          # back to original order

print(leaders([16, 17, 4, 3, 5, 2]))   # [17, 5, 2]
print(leaders([5]))                    # [5]
print(leaders([]))                     # []

Practice 2 — Find the failing input

🟡 L2

This "sum of positive numbers" passes its main test [-2, 5, 1] → 6. Find two inputs from the catalogue that break it, and classify them.

Python
def sum_positive(nums):
    total = 0
    for i in range(1, len(nums)):    # ← suspicious range
        if nums[i] > 0:
            total += nums[i]
    return total
Show Hint

Where does the loop start? What does that skip? What list makes the skip the whole story?

Show Approach

Category 2 (one element) and category 14 (first element skipped). [5] → returns 0, expected 5; [4, -1, 6] → returns 6, expected 10.

Show Solution

Failing inputs: [5] → 0 (single element: range(1, 1) never runs) and [4, -1, 6] → 6 instead of 10 (the first element, 4, is skipped on every input — the main test just happened not to have a positive first element). Classification: one-element (catalogue #2) and wrong loop start / boundary position (#14). Fix: for i in range(len(nums)): or simply for x in nums:.

Practice 3 — Rotate that survives any k

🟡 L2

Rotate a list right by k, in a way that survives k = 0, k huge (k = 10^9), and an empty list. [1, 2, 3, 4, 5], k = 2 → [4, 5, 1, 2, 3].

Show Hint

Catalogue #7 and #11: k can be 0 or larger than n. One modulo line handles both — then the empty case separately.

Show Approach

Normalize with k = k % n (needs n > 0 — guard empty first), then slice: nums[-k:] + nums[:-k] (careful: k = 0 makes nums[-0:] the wrong slice — check it!).

Show Solution
Python
def rotate(nums, k):
    n = len(nums)
    if n == 0:
        return []
    k = k % n                # huge k collapses; k = 0 stays 0
    if k == 0:
        return nums[:]       # -0 trap avoided: nums[-0:] == nums[0:] == whole list
    return nums[-k:] + nums[:-k]

print(rotate([1, 2, 3, 4, 5], 2))    # [4, 5, 1, 2, 3]
print(rotate([1, 2, 3], 0))          # [1, 2, 3]
print(rotate([1, 2, 3], 10))         # [3, 1, 2]   (10 % 3 = 1 effective step)
print(rotate([], 4))                 # []

The k = 0 guard is a genuine Python trap: nums[-0:] is nums[0:] (the whole list), not "the last zero elements".

Challenge Problem

Challenge — Two smallest distinct values

🟠 L3 · Recognize

Return the two smallest distinct values of a list, or None if there are fewer than two distinct values. Your function must survive the whole catalogue: [], [7], [4, 4], [-2, -2, -5], [3, 3, 1, 1].

Show Hint

Track best and second exactly like Story 1 — with strict boundaries and honest not-found results.

Show Approach

One pass with two sentinels (None or +/-inf): update best when x is smaller; update second only when best > x > second. Map the -inf sentinel to None at the end.

Show Solution
Python
def two_smallest_distinct(nums):
    best = float('inf')
    second = float('inf')
    for x in nums:
        if x < best:
            second = best
            best = x
        elif best > x > second:      # distinct from best, better than second
            second = x
    if second == float('inf'):
        return None                  # fewer than two distinct values
    return [best, second]

print(two_smallest_distinct([]))            # None
print(two_smallest_distinct([7]))           # None
print(two_smallest_distinct([4, 4]))        # None
print(two_smallest_distinct([-2, -2, -5]))  # [-5, -2]
print(two_smallest_distinct([3, 3, 1, 1]))  # [1, 3]

Notice how the strict chain best > x > second quietly handles duplicates, and the sentinel check handles empty / single / all-equal in one place.

Revision Checklist

PART 26

Debugging DSA Code

Your code runs and returns the wrong answer. Here is the systematic path from "mystery" to "fixed".

DSA bugs are rarely syntax errors — Python would have told you. They are logic errors: an off-by-one, a pointer that never moves, a base case that never fires. The good news is that logic errors are mechanical to hunt: make the failing input small, watch the variables that matter, and find the first moment reality disagrees with your plan. This part turns that sentence into a toolkit.

Print debugging — strategic, not spammy

Print statements are the fastest debugger you own, if you print the right things at the right places:

  • Label everything. print(i, total) is useless two hours later. print(f"i={i}, total={total}") reads like a story.
  • Print the loop's key state — the 2-4 variables that define the iteration (index and accumulator, or pointers and current sum).
  • Print once per interesting event, not once per element in a million-element loop. Or print every k-th iteration.
  • Print checkpoints: print("reached merge step") tells you how far execution got before silence.
  • Remove prints before submitting — leftover prints cost time and clutter.

Best of all, prints catch certain bugs by rhythm. This two-pointer function is missing its else branch:

Python
def has_pair(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo < hi:
        s = nums[lo] + nums[hi]
        print(f"lo={lo}, hi={hi}, s={s}")     # one labeled line per step
        if s == target:
            return True
        elif s < target:
            lo += 1
        # bug: no else! nothing moves when s > target
    return False
Output on nums = [1, 3, 4, 6, 8, 10, 13], target = 13
lo=0, hi=6, s=14
lo=0, hi=6, s=14
lo=0, hi=6, s=14
... the same line repeats forever → the pointers are frozen

You do not even need to read the numbers carefully — an identical line repeating means nothing is changing. Diagnosis: s = 14 > 13 took the hidden path and moved neither pointer. Fix: add else: hi -= 1.

Dry runs and watch variables

Before (or instead of) printing, run the code yourself on a small input with a two-column habit: what you expect each variable to be, and what it actually is. Pick a small watch list — the variables that define the loop — and ignore the rest.

Take this familiar bug (from the Edge Cases part):

Python
def sum_positive(nums):
    total = 0
    for i in range(1, len(nums)):    # ← starts at 1
        if nums[i] > 0:
            total += nums[i]
    return total
StepWatch: iExpected totalActual totalVerdict
start—00✅
process index 0 (value 4)04never happens — i starts at 1❌ first divergence
process index 1 (value -1)140❌
process index 2 (value 6)2106❌

The bug announces itself at the first divergence — the loop skips index 0 on every input. The main test merely failed to have a positive first element, so the wrong answer hid. Whenever actual first disagrees with expected, the bug is in the code that produced that step — not later.

Tip — say the invariant out loud

A loop invariant is a sentence that should be true at the start of every iteration, e.g. "best is the maximum of nums[0..i]" or "everything left of lo is too small to matter". After each step of your dry run, check the sentence. The first iteration where the sentence is false is exactly where the bug lives — and often the invariant's wording even suggests the fix.

Tracing pointers, the stack, and recursion

Different shapes of code need different traces:

Trace pointers

For two pointers or binary search, write one line per step: lo, hi, mid and the comparison result. Watch for the two classic smells: a pointer that never moves, and a step where lo passes hi without the answer being checked.

Trace the stack

For recursion, the call stack is the whole game. Print with indentation proportional to depth — the shape of the output is the recursion tree.

Python
def fib(n, depth=0):
    print("  " * depth + f"fib({n})")
    if n <= 1:
        return n
    return fib(n - 1, depth + 1) + fib(n - 2, depth + 1)

fib(4)
Output — the indentation is the call stack
fib(4)
  fib(3)
    fib(2)
      fib(1)   ← base case, returns 1
      fib(0)   ← base case, returns 0
    fib(1)     ← base case, returns 1
  fib(2)
    fib(1)
    fib(0)
→ fib(4) = 3

Read the trace top-down: every missing base case shows up as a line like fib(-1) that should not exist; every "infinite recursion" shows as the same line descending forever. If a recursive answer is wrong, find the deepest line where the returned value disagrees with your hand calculation — that frame is the culprit.

Compare expected vs actual — then walk backwards

The full method, for any code shape:

1

Compute the correct answers by hand for a tiny input (this is your expected column).

2

Run the code (or print-trace it) recording actual values of the watch list.

3

Find the first divergence between the two columns.

4

Walk backwards one step: which line produced the wrong value? That line is wrong — not the line where you noticed.

The minimal failing example — shrink until it hurts

A bug on a 500-element input is untraceable; the same bug on a 2-element input is obvious. Shrinking is a skill with a simple loop: remove part of the input; if it still fails, keep the smaller version; if it stops failing, you just learned the bug needs what you removed.

Real example. This "remove duplicates from a sorted list" passes several tests, then crashes on a bigger one:

Python
def remove_dup_sorted(nums):
    for i in range(1, len(nums)):    # range computed once, at loop start
        if nums[i] == nums[i - 1]:
            nums.pop(i)              # ← mutating while iterating
    return nums

Shrink it: [1, 1, 2, 2, 2, 3] → still crashes (IndexError). [1, 1, 2] → still crashes. [1, 1] → no crash — it returns [1], accidentally right. So the smallest failing input is [1, 1, 1]: three elements, traceable in two lines.

Trace — remove_dup_sorted([1, 1, 1])
range(1, 3)  ← computed ONCE from the original length 3
i = 1:  nums[1] == nums[0] → pop(1) → nums = [1, 1]   (length is now 2)
i = 2:  nums[2] → IndexError ❌   (the list only has indices 0..1)

The shrink revealed the bug precisely: the range was fixed when the loop started, but pop shortens the list — near the end, nums[i] walks past the end. And note what the shrink also taught us: [1, 1] passing was luck, not correctness. The fix writes itself — build a new list instead of mutating while iterating:

Python
def remove_dup_sorted(nums):
    result = []
    for x in nums:
        if not result or result[-1] != x:
            result.append(x)
    return result

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

Common DSA bugs — the bug → symptom → fix library

Nine bugs cause the overwhelming majority of wrong answers and crashes. Skim the symptom column when your code misbehaves — you will usually match it in seconds.

1 · Off-by-one errors

Symptom: last element missing, or an IndexError at index len(nums).

Fix: decide inclusive vs exclusive once (range(n) vs range(1, n)), then test on a 1- and 2-element list. Write the first and last iteration out by hand.

2 · Wrong loop range

Symptom: answer is systematically missing one element (often the first) — like the sum_positive example above.

Fix: prefer for x in nums over index loops; when you truly need indices, range(len(nums)) is the default and any deviation needs a reason.

3 · Infinite loop

Symptom: the program hangs; the same print line repeats forever.

Fix: ensure every branch of the loop body moves a loop variable. In while-loops, check the counter/pointer updates before checking the condition logic.

4 · Wrong pointer movement

Symptom: two-pointer answers are missed or pointers cross before the pair is tested.

Fix: derive movement from the comparison (sum too small → advance left; too big → retreat right), then hand-trace a 2-element input.

5 · Incorrect dictionary update

Symptom: counts off by one, KeyError on first sight of a value, or "first index" turns out to be the last.

Fix: use counts[x] = counts.get(x, 0) + 1 (or defaultdict(int)); remember writes overwrite — check-before-insert when order matters.

6 · Missing base case

Symptom: RecursionError: maximum recursion depth exceeded, or wrong answers at the smallest inputs.

Fix: list the smallest possible inputs (empty, one element, n = 0) and handle each explicitly before writing the recursive step.

7 · Forgetting to update the answer

Symptom: the function returns the initial value (0, -inf, None) no matter the input.

Fix: find where the best candidate is computed and check that best (not just curr) is updated there; return best, not the loop variable.

8 · Mutating data unexpectedly

Symptom: a second call returns something different; popping/sorting inside a loop corrupts the iteration (the remove_dup_sorted story).

Fix: iterate over a copy or build a new result; remember nums.sort() returns None and sorts in place — use sorted(nums) to keep the original.

9 · Incorrect binary search boundary

Symptom: target missed at the very first or last position, or an infinite loop when the range is 2 elements wide.

Fix: keep the consistent trio — hi = n - 1, while lo <= hi, move to mid +/- 1 — and test the first element, the last element, and a 2-element array. (See the Binary Search part for the full contract.)

Q1. Your program hangs. Which print line is the most valuable clue?
A repeated identical line means some loop body executed without changing anything — the infinite loop's fingerprint.
Q2. Spot the bug class: for i in range(len(nums) - 1): in a function that must consider every element.
range(len(nums) - 1) stops one short: for [-1, -2, -3] a "count negatives" loop returns 2, not 3.

The debugging checklist

Run this sequence in order — each step either fixes the bug or shrinks the search space:

Common Mistake — shotgun fixing

Changing three things at once and re-running is not debugging; it is rolling dice. If two changes "fix" the bug, you do not know which one mattered — and one of them may have introduced a new bug. One hypothesis, one change, one re-run, every time.

What You Learned

  • Strategic print debugging: labeled variables, key loop state, checkpoint lines — and the repeating-line tell for infinite loops.
  • Dry runs with an expected-vs-actual table; the first divergence marks the buggy step.
  • Tracing pointers (lo/hi/mid), the call stack (indented prints), and loop invariants.
  • Shrinking a failing input until it is hand-traceable — and what a passing small input can still hide.
  • A 9-entry bug library (off-by-one, loop range, infinite loop, pointer movement, dict updates, base cases, answer updates, mutation, binary search boundaries) plus a full debugging checklist.

Pattern Recognition

Match the symptom to the suspect: crash at the end → off-by-one; wrong answer missing one element → wrong loop range; hang → nothing moves in some branch; RecursionError → missing base case; answer never changes → best never updated; second run differs → mutation. In DSA code, the symptom class almost always names the bug class.

Complexity Summary

Debugging moveCostWhen it pays off
Labeled print of watch variablessecondswrong answers, hangs, pointer logic
Expected vs actual tableminutesany wrong answer, finds first divergence
Indented recursion traceminutesmissing base cases, wrong returns
Shrink to minimal failing inputminutesanything — makes every other move easier
One-hypothesis-at-a-time fixingdisciplineprevents new bugs while fixing old ones

Common Mistakes

  • Printing everything, every iteration — 10,000 lines hide the bug instead of revealing it.
  • Fixing the line where the crash happened instead of the line that caused it.
  • Changing multiple things per attempt, so no result is interpretable.
  • Declaring victory after one test — re-run the cases that passed before; optimized "fixes" break them regularly.
  • Leaving debug prints in the submission.

Mini Quiz

Q1. RecursionError: maximum recursion depth exceeded most likely means...
Missing or unreachable base case — the calls descend forever. Print with depth indentation and look for the deepest line.
Q2. The expected-vs-actual table first disagrees at step 4 of 9. Where is the bug?
Later steps inherit the wrong value. Fix the producer of the first divergence, then re-run.
Q3. Your binary search misses the target when it is the LAST element. First check:
End-of-range misses are boundary bugs: an off-by-one in hi, the loop condition, or the mid update. Test a 2-element array to see it instantly.
Q4. Why is a passing test on input [1, 1] not evidence that remove-duplicates works?
Shrinking finds the SMALLEST FAILING input; inputs smaller than it may pass accidentally. Verify the fix on the minimal failing example, not on a smaller one.

Practice Problems

Practice 1 — The missing base case

🟡 L2

This recursive sum crashes. Identify the symptom class, the failing input, and fix it.

Python
def sum_list(nums, i=0):
    return nums[i] + sum_list(nums, i + 1)
Show Hint

What is the smallest input? What happens when i reaches the end?

Show Approach

There is no base case: even the empty list indexes nums[0] (IndexError) and any list recurses past the end until it crashes. Add the base case for "past the end" and (if the contract allows) "empty".

Show Solution
Python
def sum_list(nums, i=0):
    if i == len(nums):       # base case: nothing left to add
        return 0
    return nums[i] + sum_list(nums, i + 1)

print(sum_list([1, 2, 3]))   # 6
print(sum_list([]))          # 0

Practice 2 — The drifting counter

🟡 L2

This word-frequency function returns wrong counts. Trace with watch variables on input "aba" and fix it.

Python
def word_counts(s):
    counts = {}
    for ch in s:
        counts[ch] = 1        # ← every visit resets the count
    return counts
Show Hint

Print f"ch={ch}, counts after={counts}" per step on "aba" and compare with what you expect.

Show Approach

The update always assigns 1 instead of incrementing — the dictionary-update bug (#5). Increment the old value with .get.

Show Solution
Python
def word_counts(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    return counts

print(word_counts("aba"))    # {'a': 2, 'b': 1}

Trace it: ch='a' → {'a': 1}; ch='b' → {'a': 1, 'b': 1}; ch='a' → buggy: {'a': 1, 'b': 1} (count lost!), fixed: {'a': 2, 'b': 1}. The divergence appears on the SECOND visit of a character.

Practice 3 — Shrink this failure

🟡 L2

This "max sum of k consecutive elements" returns 11 on [2, 9, 1, 7, 5] with k = 2, but the correct answer is 12 (the window 7 + 5). Shrink the input until the bug is hand-traceable, name the bug class, and fix it.

Python
def max_sum_k(nums, k):
    best = 0
    for i in range(len(nums) - k):
        s = sum(nums[i:i + k])
        best = max(best, s)
    return best
Show Hint

How many windows of size k exist in a list of length n? The loop runs from 0 to len(nums) - k - 1. Count both.

Show Approach

Shrink: [2, 9, 1, 7, 5] fails. Try [7, 5] with k = 2 → returns 0 (one window exists, none checked!). Or [1, 2] with k = 1 → returns 1, correct is 2. The last window is never examined.

Show Solution

Minimal failing input: [1, 2], k = 1. Trace: there are 2 windows of size 1 — [1] and [2] — but range(len(nums) - k) = range(1) checks only i = 0. The loop count is off by one: a list of length n has n - k + 1 windows of size k, not n - k. Bug class: off-by-one / wrong loop range (#1). Fix: range(len(nums) - k + 1).

Python
def max_sum_k(nums, k):
    best = sum(nums[:k])            # seed from a real window (also fixes best = 0)
    for i in range(len(nums) - k + 1):
        s = sum(nums[i:i + k])
        best = max(best, s)
    return best

print(max_sum_k([2, 9, 1, 7, 5], 2))   # 12 ✅

Note the second, quieter bug the shrink exposed: best = 0 would also fail on all-negative inputs — seeding from sum(nums[:k]) fixes both at once.

Challenge Problem

Challenge — The three-bug binary search

🟠 L3 · Recognize

This binary search was submitted and failed 4 of 12 tests. It contains three distinct bugs. Find all three (classify each with the bug library), state a minimal failing input for each, and write the corrected version.

Python
def binary_search(nums, target):
    lo = 0
    hi = len(nums)                # bug candidate 1?
    while lo < hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            lo = mid              # bug candidate 2?
        else:
            hi = mid - 1
    return -1
Show Hint

Check the boundary trio for mutual consistency: hi = n with hi = mid - 1? Then ask two questions: when the window is two cells wide (hi = lo + 1), what is mid and does lo = mid move anything? And after hi = mid - 1 lands hi at 0, is index 0 ever examined?

Show Solution

Bug 1 — pointer stall (wrong movement): lo = mid. When the window is two cells wide (hi = lo + 1), mid = (lo + lo + 1) // 2 = lo, so lo = mid changes nothing and the loop spins forever. Symptom: hang. Minimal failing input: [1, 3, 5, 7], target 4 — lo freezes at 0 (mid = 0, nums[0] = 1 < 4, lo = mid = 0, forever).

Bug 2 — boundary init mismatch: hi = len(nums) points one past the last valid index, but the update hi = mid - 1 treats hi as an inclusive index. The mixed convention lets hi collapse to 0 and end the loop. Symptom: wrong answer, targets missed near the start. Minimal failing input: [1, 3, 5], target 1 → lo=0, hi=3, mid=1 → nums[1]=3 > 1 → hi = 0 → loop exits → returns -1, but the target sits at index 0.

Bug 3 — loop condition mismatch: with an inclusive hi, while lo < hi refuses to examine the final one-cell range where lo == hi — exactly where the missed targets land. All three bugs are really one disease: the trio (init, condition, movement) must follow a single convention.

Python
def binary_search(nums, target):
    lo = 0
    hi = len(nums) - 1            # fix 1: last VALID index
    while lo <= hi:               # fix 2: <= so a 1-cell range is checked
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            lo = mid + 1          # fix 3: mid is ruled out, step past it
        else:
            hi = mid - 1
    return -1

print(binary_search([1, 3, 5], 1))     # 0   (the missed first element)
print(binary_search([1, 3, 5], 5))     # 2
print(binary_search([1, 3], 2))        # -1  (terminates)
print(binary_search([], 7))            # -1  (empty edge)

Revision Checklist

PART 27

Common DSA Mistakes

A field guide to the thirteen failures almost every beginner makes — and the habit that prevents each one.

Nobody invents a new mistake. The same thirteen show up in thousands of practice sessions: reading the problem, choosing tools, writing loops, and learning from solutions. Scan this library before a contest or interview, and — more usefully — when you are stuck: your block probably has a name below.

Mistakes 1-7 — the setup and the tools

1 · Jumping into code too early

Sound familiar? Fingers on the keyboard before the brain has solved the example. Twenty minutes later the code "almost works" — but no one, including you, knows what it is supposed to do.

Prevention: restate the problem in one sentence, then solve one example by hand and write down the steps you took. If you cannot narrate the steps, you are not ready to type them.

2 · Ignoring constraints

Sound familiar? A beautiful O(n²) double loop — submitted against n = 100,000. Time limit exceeded, and the "better" idea was never needed for the examples.

Prevention: translate constraints into a complexity budget before designing: n <= 10^5 means roughly O(n log n) or better (about 10^7 operations). If your plan exceeds the budget, upgrade before coding, not after a TLE.

3 · Ignoring sortedness

Sound familiar? The statement says "sorted array" and you build a hash set anyway — correct, but it ignores a free gift: order enables binary search and two pointers, often with O(1) extra space.

Prevention: underline every structural promise in the statement ("sorted", "unique", "non-negative") and ask: what does this property let me skip?

4 · Nested loops, unnecessarily

Sound familiar? An inner loop that re-scans the array "to check" something on every iteration — a search, a sum, a max — hiding an O(n) inside your O(n).

Prevention: use the Part 24 recipe: name the repeated work in one sentence. A repeated lookup wants a hash map; a repeated sum wants a running total.

5 · Wrong data structure

Sound familiar? if x in some_list inside a loop (O(n) per check), or popping from the front of a list (O(n) per pop) when a set or deque is the right tool.

Prevention: choose the structure by the dominant operation: membership → set/dict, front-removal → deque, smallest-first → heap. One question: "what will I do most often?"

6 · Not checking edge cases

Sound familiar? The provided example passes; the judge's second test — an empty list, or one negative number — fails. The bug was visible from the start, in the code's assumptions.

Prevention: run the Part 25 checklist: empty, single, duplicates, negatives, boundaries — two minutes, before submitting.

7 · Wrong complexity estimate

Sound familiar? "My solution is O(n)" — but the loop contains nums[i:j] slicing, in on a list, or a sort(), each secretly O(n) or worse per step.

Prevention: count the hidden costs: slicing copies (O(k)), x in list (O(n)), list.sort() (O(n log n)). Complexity is the sum of everything inside the loop, not just the loop.

Q1. n can be 100,000. Roughly what operation budget does that imply?
Judges execute roughly 10^7-10^8 simple operations per second; n² = 10^10 is minutes. Match the constraint to the budget before designing.

Mistakes 8-13 — approach, coding, learning

8 · Copying solutions without understanding

Sound familiar? You read the editorial, nodded, moved on — and the next problem with the same pattern is still unsolvable. Nothing stuck, because recognition was borrowed instead of built.

Prevention: close the solution and re-code it from a blank file. Then explain each line out loud ("this variable holds the best window ending here"). Re-solve again two days later.

9 · Overcomplicating simple problems

Sound familiar? A counting question answered with recursion and memoization; a "find the max" implemented with classes and queues. Complexity you added is complexity you must debug.

Prevention: try the dumbest correct solution first. If it fits the constraint budget, it is not "too simple" — it is done. Add machinery only when a requirement demands it.

10 · Underthinking pointer movement

Sound familiar? Two pointers that move by intuition ("this feels right") — and skip the answer on certain inputs, or cross without testing a pair.

Prevention: justify every move with an elimination argument ("sum too big → nums[right] has no partner left"). If you cannot argue why the move is safe, it is not safe. Then hand-trace a 2-element input.

11 · Binary search boundary mistakes

Sound familiar? hi = len(nums) with while lo <= hi — IndexError. Or lo = mid — infinite loop. Or the target at the last index is never found.

Prevention: pick ONE consistent trio: hi = n - 1, while lo <= hi, move to mid +/- 1. Test three probes every time: first element, last element, 2-element array.

12 · Recursion without a clear base case

Sound familiar? RecursionError: maximum recursion depth exceeded — or a recursive function that returns the wrong value only for the empty list.

Prevention: write the base cases FIRST, before the recursive step: empty input, single element, n = 0. Enumerate them like edge cases (because they are).

13 · Changing variables without understanding their role

Sound familiar? "Debugging" by tweaking: move the +1, swap the comparison, rename the variable — until the test passes. It passes today and breaks tomorrow, because nothing was understood.

Prevention: give every variable a spoken role ("curr = best window ending here", "lo = first index not yet ruled out"). Change one thing at a time, with a predicted outcome, per the Part 26 method.

Q2. for x in nums: if x in visited_list: — where visited_list grows to n. What is the real complexity?
The hidden cost: membership in a list is O(n). Swap the list for a set and the same code becomes genuinely O(n) — that is mistake #5 fixing mistake #7.
Q3. Which probe set catches nearly every binary search boundary bug?
Boundary bugs live at the extremes of the range. The three probes exercise hi init, the loop condition, and both movement branches in seconds.

Mistake self-audit

Be honest with the last five problems you wrote. Check each line you can honestly claim — the unchecked ones are your next practice priorities.

What You Learned

  • Thirteen named mistakes: 1-3 strike before you code, 4-7 while choosing tools, 9-13 while writing code, and 8 while learning from others.
  • Each mistake has a prevention habit, and the habits compound: the constraint budget (2) feeds the structure choice (5), which feeds the loop shape (4).
  • A 10-point self-audit you can run over your last five problems to find your personal repeat offenders.
  • Being stuck rarely means a new problem: almost every block has a name in this library — find it, apply its prevention.

Pattern Recognition

Mistakes announce themselves mid-problem, not just in verdicts. Feeling eager to type? → mistake 1. Re-reading the constraints? → mistake 2 is about to happen. Writing if x in list inside a loop? → mistakes 5 and 7. Two pointers moving "by feel"? → mistake 10. Nodding at an editorial? → mistake 8. The library works best as a live checklist, consulted while you work — not just after you fail.

Complexity Summary

The constraint budget that mistake 2 asks you to check — before designing, every time:

Constraint on nComfortable complexityTypical toolsDeath row
n <= 10anything, even O(n!)brute force, recursion—
n <= 1,000O(n²)nested loops, DP tablesO(2^n), O(n!)
n <= 100,000O(n log n) or O(n)sort, heap, hash map, two pointersO(n²)
n <= 10,000,000O(n)single pass, sieveO(n log n) with heavy constants

Common Mistakes

  • Reading this library once and never auditing your own work against it — the audit is where it pays.
  • Treating "write the dumbest correct solution first" as beneath you; it is the fastest route to a working baseline.
  • Confusing "I recognize this pattern" with "I can implement it from a blank file" — only the second is knowledge (mistake 8).
  • Skipping straight to hints when stuck instead of finding your mistake's name first.
  • Fixing symptoms by tweaking (mistake 13) instead of restating the variable's role.

Mini Quiz

Q1. Thirty minutes, a blank page, a new problem. What comes first?
Mistake 1: the hand-solved example is both your design and your first test case.
Q2. n <= 100,000. Which plan is acceptable?
n² = 10^10 operations is minutes; n log n is well under a second. The budget decides.
Q3. The statement says "sorted array". Your first thought should be...
Sortedness is a free structural gift (mistake 3). Re-sorting sorted data wastes the gift and sometimes breaks index-based answers.
Q4. You just read a clean solution to a sliding-window problem. The learning happens when...
Mistake 8: understanding is demonstrated by production, not recognition. The blank-file re-code is the test.

Practice Problems

Practice 1 — Diagnose and fix the approach

🟢 L1

A friend solves "find whether two values in a sorted array sum to the target" with a nested double loop. Which library mistake is this, and what is the fixed approach?

Show Hint

Two promises were ignored at once: the sortedness, and the cost of n² at the stated n.

Show Approach

Mistakes 3 (ignoring sortedness) and 2 (constraints). Sorted + pair-sum → two pointers moving inward, O(n) time, O(1) space.

Show Solution

Pointers at both ends; if the sum is too small advance left, too big retreat right. Each step eliminates one element with a one-sentence justification (Part 24, Problem 24.3). If the array were unsorted, the honest fix would be a hash map — a different mistake, a different tool.

Practice 2 — The complexity audit

🟡 L2

This "all unique characters?" check is O(n²) though it looks like one loop. Find the hidden cost and fix the structure choice.

Python
def all_unique(s):
    seen = []
    for ch in s:
        if ch in seen:        # ← the hidden O(n)
            return False
        seen.append(ch)
    return True
Show Hint

Mistakes 5 and 7 at once: what is the dominant operation here, and what does ch in seen cost on a list?

Show Approach

Membership is the dominant operation → set. ch in set is O(1) average, making the whole scan O(n).

Show Solution
Python
def all_unique(s):
    seen = set()
    for ch in s:
        if ch in seen:
            return False
        seen.add(ch)
    return True

print(all_unique("python"))   # True
print(all_unique("banana"))   # False

Practice 3 — Own the copied solution

🟡 L2

You "know" Kadane from Part 24 — but could you re-derive it? Without looking back: write the one-sentence role of curr and best, and explain why restart happens exactly when curr goes negative. Then re-code it from scratch.

Show Hint

Roles, not types: "curr = the best sum of a window that ends at the current element" — say it in one breath.

Show Approach

If the best window ending at the previous element is negative, attaching it to x only hurts; so curr = max(x, curr + x) chooses restart precisely when curr < 0. best = the best window ending anywhere so far.

Show Solution
Python
def max_subarray_kadane(nums):
    best = nums[0]            # best window seen anywhere so far
    curr = nums[0]            # best window ending at the current element
    for x in nums[1:]:
        curr = max(x, curr + x)   # extend, or restart when the past hurts
        best = max(best, curr)
    return best

print(max_subarray_kadane([-2, 1, -3, 4, -1, 2, 1, -5, 4]))   # 6

If you could not do this from memory, that is mistake 8 revealing itself — which is exactly what this practice is for.

Challenge Problem

Challenge — The flawed interview answer

🟠 L3 · Recognize

A candidate is asked: "Given a sorted array and a target, return indices of the pair summing to the target, or [-1, -1]. n can be 200,000." Their answer: "I'll loop over all pairs with two nested loops; inside, to be fast, I'll check if target - nums[i] in nums first; I'll test it on the sample input [1, 2, 3, 4] target 7; and if that's too slow I'll add binary search with lo = mid when the value is bigger." Identify at least five library mistakes in this answer and write the corrected plan.

Show Hint

Scan the four zones: before coding, approach, coding, verification. The phrase "if that's too slow" is itself a mistake.

Show Approach

Count: constraints vs O(n²)? sortedness used? hidden cost of in on a list? testing only the happy sample? binary search boundary trio (lo = mid)? brute force as a plan rather than a baseline?

Show Solution
  • Mistake 2 (constraints): n = 200,000 makes O(n²) = 4 · 10^10 — dead on arrival. The budget demanded O(n log n) or O(n) before writing anything.
  • Mistake 4 (nested loops unnecessarily): the pair loop is the O(n²) itself.
  • Mistake 3 (ignoring sortedness): the single most important word in the statement, unused.
  • Mistake 7 (wrong complexity estimate): target - nums[i] in nums is an O(n) scan inside a loop — the "speedup" keeps the total at O(n²).
  • Mistake 6 (not checking edge cases): one happy sample; no empty array, no pair at the ends, no negative target.
  • Mistake 11 (binary search boundaries): lo = mid is the stall bug — it must be lo = mid + 1 (and the trio must stay consistent).
  • Mistake 1 (planning the optimization for later): "if that's too slow I'll fix it" inverts the process — the budget check comes first (mistake 2).

Corrected plan: two pointers from both ends (sorted!): sum too small → advance left; too big → retreat right; equal → return the pair; pointers meet → [-1, -1]. O(n) time, O(1) space, every move justified by the elimination argument. Then run the edge checklist: empty array, no-pair case, duplicates like [3, 3] with target 6.

Revision Checklist

PART 28

Problem-Solving Templates

Your armory: one clean, commented starting point for every pattern in this book.

You do not solve a problem from a blank page. You recognize the shape of the problem, pull the matching template, and adapt it. This part collects the 16 core patterns as minimal Python templates — beginner-style, no clever tricks, each one small enough to rewrite from memory after a few uses. Every template carries a when to reach for it line and trigger tag chips that mirror the clue words you will see in problem statements.

Tip — how to use the armory

1. Read the problem and scan for the trigger tags. 2. Copy the closest template. 3. Rename the variables to match the story. 4. Change only the "do work" line. 5. Trace it once on the sample input before trusting it. A template you can trace is a template you can modify with confidence.

Arrays & Hashing

Template 1 — Array Traversal

Reach for it when: every element must be visited once — to scan, transform, or accumulate.

single pass visit every element O(n)

Python
def process_each(nums):
    result = []
    for x in nums:                # visits every element exactly once
        result.append(x * 2)      # replace this line with the real work
    return result

def process_with_index(nums):
    for i in range(len(nums)):    # use this shape when you need the index
        print(i, nums[i])

Template 2 — Frequency Map

Reach for it when: you must count things or answer "how many of X have I seen so far?".

count most frequent anagram

Python
def build_frequency(items):
    freq = {}                     # item -> how many times seen so far
    for x in items:
        if x in freq:
            freq[x] += 1
        else:
            freq[x] = 1
    return freq
    # shortcut for the two body lines: freq[x] = freq.get(x, 0) + 1

Template 3 — Set Lookup ("have I seen this?")

Reach for it when: the question is about existence or duplicates, not counts.

duplicate seen before membership

Python
def seen_before(nums):
    seen = set()
    for x in nums:
        if x in seen:             # O(1) average membership test
            return True
        seen.add(x)
    return False

Two Pointers

Template 4 — Two Pointers, Opposite Ends

Reach for it when: the array is sorted and you are looking for a pair (or triplet) with a condition.

sorted pair sum palindrome O(1) space

Python
def pair_sum_sorted(nums, target):
    # nums must be sorted
    left, right = 0, len(nums) - 1
    while left < right:
        total = nums[left] + nums[right]
        if total == target:
            return (left, right)
        elif total < target:
            left += 1             # sum too small → need a bigger value
        else:
            right -= 1            # sum too big → need a smaller value
    return None

Template 5 — Two Pointers, Same Direction (read / write)

Reach for it when: you must filter or compact an array in place — keep some, drop the rest.

in place remove / move elements two indices

Python
def keep_non_zero(nums):
    write = 0                     # next slot to fill with a kept value
    for read in range(len(nums)):
        if nums[read] != 0:       # the "keep" condition
            nums[write] = nums[read]
            write += 1
    return write                  # nums[:write] hold the kept values

Sliding Window

Template 6 — Sliding Window, Fixed Size k

Reach for it when: the statement says "subarray / substring of size k".

size k contiguous max / min / average

Python
def max_sum_size_k(nums, k):
    window = sum(nums[:k])        # first window, computed once
    best = window
    for right in range(k, len(nums)):
        window += nums[right] - nums[right - k]   # add entering, drop leaving
        best = max(best, window)
    return best

Template 7 — Sliding Window, Variable Size

Reach for it when: you need the longest or shortest window satisfying a condition, and the size is not given.

longest / shortest subarray or substring grow and shrink

Python
def longest_all_unique(s):
    in_window = set()
    left = 0
    best = 0
    for right in range(len(s)):
        while s[right] in in_window:   # window invalid → shrink from the left
            in_window.remove(s[left])
            left += 1
        in_window.add(s[right])
        best = max(best, right - left + 1)
    return best

Prefix Sum

Template 8 — Prefix Sum Build + Range Query

Reach for it when: many "sum of a range" questions — or counting subarrays by their running sum.

range sum subarray sum precompute

Python
def build_prefix(nums):
    prefix = [0] * (len(nums) + 1)     # prefix[i] = sum of the first i elements
    for i, x in enumerate(nums):
        prefix[i + 1] = prefix[i] + x
    return prefix

def range_sum(prefix, left, right):
    # sum of nums[left..right], both ends included
    return prefix[right + 1] - prefix[left]

Binary Search

The three variants differ in one or two lines. Part 10 walks every dry run.

Reach for it when: the data is sorted (or the answer space is monotonic) and you need O(log n).

sorted search O(log n)

Python
def binary_search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            lo = mid + 1              # target is in the right half
        else:
            hi = mid - 1              # target is in the left half
    return -1

Reach for it when: duplicates exist and you need the leftmost match (or "first index where…").

Python
def first_occurrence(nums, target):
    lo, hi = 0, len(nums) - 1
    ans = -1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            ans = mid             # candidate — keep hunting to the LEFT
            hi = mid - 1
        elif nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return ans

Reach for it when: you need the rightmost match (or "last index where…", "count = last − first + 1").

Python
def last_occurrence(nums, target):
    lo, hi = 0, len(nums) - 1
    ans = -1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            ans = mid             # candidate — keep hunting to the RIGHT
            lo = mid + 1
        elif nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return ans

Stack & Queue

Template 9 — Stack (most-recent-first)

Reach for it when: you must match, undo, or process the most recent unfinished thing.

matching brackets undo nested structure

Python
def is_balanced_parens(s):
    stack = []
    pairs = {')': '(', ']': '[', '}': '{'}
    for ch in s:
        if ch in "([{":
            stack.append(ch)          # opening: remember it
        else:                         # closing: must match the latest opener
            if not stack or stack[-1] != pairs[ch]:
                return False
            stack.pop()
    return len(stack) == 0            # True only if everything matched

Template 10 — Queue / Deque (first-in-first-out)

Reach for it when: order of arrival must be preserved — processing jobs, BFS, round-robin.

in order level by level line of items

Python
from collections import deque

def serve_in_order(items):
    q = deque(items)
    results = []
    while q:                      # FIFO: the front leaves first
        item = q.popleft()        # O(1) — list.pop(0) would be O(n)
        results.append(item)
        # need to schedule follow-up work? q.append(new_item) joins the back
    return results

Linked List

All list templates share these two helpers (Part 13 builds them up slowly):

Python
class Node:
    def __init__(self, val):
        self.val = val
        self.next = None

def build_list(values):
    head = None
    for v in reversed(values):    # build back-to-front
        node = Node(v)
        node.next = head
        head = node
    return head

Template 11 — Linked List Traversal

Reach for it when: you need to visit, count, or search nodes.

walk the list curr = curr.next

Python
def traverse(head):
    curr = head
    while curr:                   # stops AFTER the last node (curr is None)
        # do work with curr.val here (collect / count / compare)
        curr = curr.next

Template 12 — Insert at the Head

Reach for it when: O(1) insertion — prepending is the cheapest list operation.

insert new head order matters

Python
def insert_head(head, val):
    node = Node(val)
    node.next = head          # step 1: new node points at the old head
    return node               # step 2: the new node IS the head now

Template 13 — Delete by Value

Reach for it when: you must unlink a node — the dummy head kills every special case.

unlink dummy node rewire .next

Python
def delete_value(head, val):
    dummy = Node(0)           # dummy node before the head
    dummy.next = head
    curr = dummy
    while curr.next:
        if curr.next.val == val:
            curr.next = curr.next.next   # unlink the matching node
            break
        curr = curr.next
    return dummy.next         # new head (unchanged if val was not found)

Trees

Tree templates assume this node class (Part 16 for the full treatment):

Python
class TreeNode:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None

Template 14 — Recursive Traversals (pre / in / post)

Reach for it when: the problem decomposes into "do something with root + left subtree + right subtree".

root left right recursion every node

Python
# collect(x) stands for whatever "visiting" means here:
# print, append to a list, add to a sum, compare, ...

def preorder(node):           # Root → Left → Right
    if node is None:          # base case: empty subtree
        return
    collect(node.val)         # visit the root FIRST
    preorder(node.left)
    preorder(node.right)

def inorder(node):            # Left → Root → Right (sorted order for a BST)
    if node is None:
        return
    inorder(node.left)
    collect(node.val)         # visit in the MIDDLE
    inorder(node.right)

def postorder(node):          # Left → Right → Root (children before parent)
    if node is None:
        return
    postorder(node.left)
    postorder(node.right)
    collect(node.val)         # visit LAST

Template 15 — Iterative Inorder (explicit stack)

Reach for it when: you need inorder without recursion — the stack remembers the path for you.

no recursion slide left stack of ancestors

Python
def inorder_iterative(root):
    stack = []
    curr = root
    values = []
    while curr or stack:
        while curr:               # slide all the way left,
            stack.append(curr)    # remembering the path on the stack
            curr = curr.left
        curr = stack.pop()        # leftmost unvisited node
        values.append(curr.val)   # visit it
        curr = curr.right         # then try its right subtree
    return values

Template 16 — Level Order (BFS on a tree)

Reach for it when: the problem says "level by level", "per row", or "minimum depth steps".

level order queue per-level size

Python
from collections import deque

def level_order(root):
    if root is None:
        return []
    result = []
    q = deque([root])
    while q:
        level = []
        for _ in range(len(q)):   # freeze: exactly this level's nodes
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        result.append(level)
    return result

Graphs

A graph is just graph = {node: [neighbour, ...]}. A grid is a graph whose neighbours are the 4 adjacent cells. Part 18 explains both views.

Template 17 — BFS on a Graph

Reach for it when: shortest path in an unweighted graph, or "closest / fewest steps".

queue visited set shortest

Python
from collections import deque

def bfs(graph, start):
    visited = {start}
    q = deque([start])
    order = []
    while q:
        node = q.popleft()
        order.append(node)            # process node here
        for nxt in graph[node]:
            if nxt not in visited:    # mark BEFORE enqueueing — no duplicates
                visited.add(nxt)
                q.append(nxt)
    return order

Template 18 — BFS on a Grid

Reach for it when: spreading through cells — floods, rotting, shortest escape.

grid 4 directions bounds check

Python
from collections import deque

def bfs_grid(grid, sr, sc):
    rows, cols = len(grid), len(grid[0])
    visited = {(sr, sc)}
    q = deque([(sr, sc)])
    while q:
        r, c = q.popleft()
        # work with grid[r][c] here
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):   # D U R L
            nr, nc = r + dr, c + dc
            if (0 <= nr < rows and 0 <= nc < cols
                    and (nr, nc) not in visited):
                visited.add((nr, nc))
                q.append((nr, nc))
    return len(visited)

Template 19 — DFS (recursive)

Reach for it when: you must explore everything reachable and the order does not matter.

explore all recursion visited set

Python
def dfs(graph, node, visited):
    visited.add(node)
    # process node here, before exploring its neighbours
    for nxt in graph[node]:
        if nxt not in visited:
            dfs(graph, nxt, visited)

def dfs_all(graph, start):
    visited = set()
    dfs(graph, start, visited)
    return visited

Heap

Template 20 — Min-Heap Basics

Reach for it when: you repeatedly need the current smallest (or largest) of a changing collection.

smallest / largest repeatedly priority

Python
import heapq

nums = [5, 1, 3, 2]
heap = []
for x in nums:
    heapq.heappush(heap, x)   # O(log n)

heap[0]                       # peek: always the MINIMUM, O(1)
heapq.heappop(heap)           # remove + return the minimum, O(log n)

# need a MAX-heap? push negatives and negate on the way out:
#   heapq.heappush(heap, -x)   ...   biggest = -heap[0]

Template 21 — Size-k Heap ("kth largest / top k")

Reach for it when: the statement says kth largest, top k, or most frequent.

kth largest top k O(n log k)

Python
import heapq

def k_largest(nums, k):
    heap = []                     # min-heap holding the k largest so far
    for x in nums:
        if len(heap) < k:
            heapq.heappush(heap, x)
        elif x > heap[0]:         # beats the smallest of the current k?
            heapq.heapreplace(heap, x)   # evict it, take the newcomer
    return sorted(heap, reverse=True)

Backtracking

Template 22 — Choose → Explore → Undo

Reach for it when: the answer is a collection of configurations — subsets, permutations, paths, boards.

all subsets all orderings try and backtrack

Python
def subsets(nums):
    result = []
    current = []

    def backtrack(start):
        result.append(current[:])        # record a COPY of the state
        for i in range(start, len(nums)):
            current.append(nums[i])      # 1. choose
            backtrack(i + 1)             # 2. explore
            current.pop()                # 3. undo

    backtrack(0)
    return result
Common Mistake

Forgetting the undo line (or recording current without the [:] copy) leaks state between branches — you get wrong, shortened answers. Trace the subsets of [1, 2] by hand once: [], [1], [1,2], [2].

Intervals

Template 23 — Merge Intervals

Reach for it when: any problem hands you [start, end] pairs and asks about overlaps.

start / end times overlap sort by start

Python
def merge_intervals(intervals):
    intervals.sort(key=lambda iv: iv[0])    # sort by start — always first
    merged = [intervals[0]]
    for start, end in intervals[1:]:
        last = merged[-1]
        if start <= last[1]:                # overlaps the last merged interval
            last[1] = max(last[1], end)     # extend it (use max: contained ends!)
        else:
            merged.append([start, end])     # a new separate interval
    return merged
Pattern Insight — templates compose

Real problems are usually two templates glued together: "count subarrays summing to k" = prefix sum + hash map. "Top k frequent" = frequency map + size-k heap. "Rotting oranges" = grid BFS + level counting. When one template is not enough, look for a second one that handles the other half of the question.

What You Learned

  • The 16 core patterns as copy-adapt-and-trace templates, not memorized code.
  • Each template's trigger tags: the clue words in a statement that point to it.
  • Both two-pointer directions, both window kinds, all three binary-search variants.
  • The choose → explore → undo rhythm of backtracking — and why the undo exists.
  • Templates compose: many problems are two patterns joined at one line.

Pattern Recognition

When you meet a new problem, do not ask "what is the answer?" first — ask "which template does this look like?" Keywords do the pointing: count / seen before → hashing; sorted + pair → two pointers; contiguous + longest/shortest → sliding window; range sums → prefix sum; sorted + O(log n) → binary search; most recent / nested → stack; in order / level by level → queue; kth / top k → heap; all configurations → backtracking; start/end pairs → intervals. Part 31 trains exactly this reflex under pressure.

Complexity Summary

TemplateTimeExtra Space
Traversal / frequency map / set lookupO(n)O(1) / O(n) / O(n)
Two pointers (either direction)O(n)O(1)
Sliding window (fixed / variable)O(n)O(1) / O(k)
Prefix sumO(n) build, O(1) per queryO(n)
Binary searchO(log n)O(1)
Stack / queue / dequeO(n) total opsO(n)
Linked list opsO(n)O(1)
Tree traversalsO(n)O(h) stack, O(w) queue
BFS / DFS on a graphO(V + E)O(V)
Size-k heapO(n log k)O(k)
Backtracking (subsets)O(2ⁿ) states — small n onlyO(n) recursion
Interval mergeO(n log n)O(n)

Common Mistakes

  • Using opposite-end two pointers on an unsorted array — the logic needs sorted order.
  • Variable window: forgetting the inner while, so the window never shrinks.
  • Binary search: mixing conventions (lo <= hi with hi = mid) → infinite loop.
  • Backtracking: missing the undo, or appending the list itself instead of a copy.
  • Interval merge: extending with end instead of max(last_end, end) — contained intervals get clipped.
  • Using list.pop(0) as a queue — O(n) per pop; deque.popleft() is O(1).

Mini Quiz

Q1. "Find the maximum average of any subarray of size k." Which template?
"Of size k" is the trigger for the fixed window: add the entering element, drop the leaving one, one pass.
Q2. Python's heapq module gives you a…
heapq is a min-heap. For "k largest" you still use a min-heap — of size k — because the smallest of the k largest sits on top, ready to be evicted.
Q3. With prefix sums, the sum of nums[left..right] equals…
prefix has one extra leading 0, so prefix[i] = sum of the first i elements. The inclusive range left..right is prefix[right + 1] − prefix[left].
Q4. The three beats of every backtracking step are…
Make a choice, recurse into the smaller problem, then undo the choice so the next branch starts from a clean state.
Q5. In the grid BFS template, how many neighbours does each cell get?
The standard grid model uses 4-directional movement; diagonal moves are a deliberate variation, not the default.

Practice Problems

Practice — Count the Vowels

🟢 L1

Adapt Template 1: count how many vowels are in a string. "hello" → 2.

Show Hint

The template's "do work" line becomes a membership test against a vowel string.

Show Approach

Walk every character; add 1 when ch in "aeiou".

Show Solution
Python
def count_vowels(s):
    count = 0
    for ch in s:
        if ch in "aeiou":     # the only line you had to change
            count += 1
    return count

Pattern clue: "visit every element once" → plain traversal.

Practice — First Non-Repeating Character

🟡 L2

Adapt Template 2: return the first character that appears exactly once, or None. "aabccbd" → "d".

Show Hint

Two passes: one to count, one to find — order matters.

Show Approach

Build the frequency map, then re-scan the string in order and return the first char whose count is 1.

Show Solution
Python
def first_non_repeating(s):
    freq = {}
    for ch in s:                      # pass 1: count
        freq[ch] = freq.get(ch, 0) + 1
    for ch in s:                      # pass 2: find in original order
        if freq[ch] == 1:
            return ch
    return None

Pattern clue: "first … that appears exactly once" → frequency map + ordered re-scan.

Practice — Move Zeroes to the End

🟡 L2

Adapt Template 5, then finish the tail: move all zeroes to the end in place, keeping order. [0,1,0,3] → [1,3,0,0].

Show Hint

The write pointer keeps the non-zeroes; what is left behind?

Show Approach

After the read/write pass fills slots [0..write), overwrite everything from write to the end with zeroes.

Show Solution
Python
def move_zeroes(nums):
    write = 0
    for read in range(len(nums)):
        if nums[read] != 0:
            nums[write] = nums[read]
            write += 1
    for i in range(write, len(nums)):   # fill the leftover slots
        nums[i] = 0
    return nums

Pattern clue: "in place, keep some, drop the rest" → same-direction two pointers.

Challenge Problem

Challenge — Count Subarrays Summing to k

🟠 L3 · Recognize
Problem

Given nums (values may be negative) and an integer k, count the contiguous subarrays whose elements sum to exactly k. n can be 200,000 — nested loops will not fit.

Input / Output

nums = [1, 1, 1], k = 2 → 2 (the subarrays [1,1] starting at index 0 and 1)

Show Hint

Reach for two templates at once: the running sum (prefix) and a map that remembers what you have seen.

Show Approach

A subarray ending at index i sums to k exactly when running − k appeared as an earlier running sum. Count those occurrences in a dictionary seeded with {0: 1}.

Show Solution
Python
def count_subarrays_sum_k(nums, k):
    seen = {0: 1}          # running sum -> how many times we've seen it
    running = 0
    count = 0
    for x in nums:
        running += x
        count += seen.get(running - k, 0)   # earlier starts that complete a k-subarray
        seen[running] = seen.get(running, 0) + 1
    return count
Pattern Recognition
Pattern Insight

"Subarray sums" + "values may be negative" kills the sliding window and leaves prefix sum + hash map — two armory templates composing into one loop.

Revision Checklist

PART 29

Problem Walkthroughs Library

The practice gym: 48 quick-fire problems, grouped by pattern, each with hint → approach → solution.

The rest of this book walked roughly a hundred problems through slowly, with dry runs and diagrams. This part is different: it is a library. Each card gives you the statement, a tiny example, and four reveals — Hint, Approach, Solution, Pattern clue. Attempt honestly, open the reveals one at a time, and keep score of which patterns cost you the most reveals.

How to use the gym

For every card: (1) read the statement and the example, (2) say the approach out loud in one sentence, (3) open Show Hint only if stuck, (4) open Show Solution only after you have written something, even brute force. Every solution is ≤ 18 lines and beginner-style — short enough to check by tracing, exactly like the Part 28 templates.

Hashing — the instant-lookup family

Trade a little memory for instant answers: "have I seen this?", "how many?", "group by signature".

29.1 Contains Nearby Duplicate

🟢 Easy

Return True if some value appears at two indices i < j with j − i ≤ k.

[1,2,3,1], k = 3 → True · [1,2,3,1], k = 2 → False

Show Hint

You only ever need the most recent index of each value.

Show Approach

Walk the array keeping a dict value → last index. If the current value is already there and the gap is within k, done; otherwise update its last index.

Show Solution
Python
def contains_nearby_duplicate(nums, k):
    last_index = {}
    for i, x in enumerate(nums):
        if x in last_index and i - last_index[x] <= k:
            return True
        last_index[x] = i        # most recent position of x
    return False

Pattern clue: "value seen before, within a distance" → remember the last position of each value in a dict.

29.2 Ransom Note

🟢 Easy

Can you build ransom using the letters of magazine (each letter used once)?

"aa", "aab" → True · "aa", "ab" → False

Show Hint

Count what you have, then spend it.

Show Approach

Build a letter count of the magazine; for each ransom letter, decrement — if the count is already 0, it fails.

Show Solution
Python
def can_build(ransom, magazine):
    letters = {}
    for ch in magazine:
        letters[ch] = letters.get(ch, 0) + 1
    for ch in ransom:
        if letters.get(ch, 0) == 0:
            return False          # ran out of this letter
        letters[ch] -= 1
    return True

Pattern clue: "can these pieces build that?" → frequency map, consume as you go.

29.3 Isomorphic Strings

🟡 Easy+

Can the letters of s be replaced (one-to-one) to get t?

"egg", "add" → True · "foo", "bar" → False · "badc", "baba" → False

Show Hint

One map is not enough — "badc"/"baba" needs both directions to be consistent.

Show Approach

Keep two dicts: s→t and t→s. On each pair of letters, both mappings must already agree; otherwise fail.

Show Solution
Python
def is_isomorphic(s, t):
    if len(s) != len(t):
        return False
    s_to_t, t_to_s = {}, {}
    for a, b in zip(s, t):
        if a in s_to_t and s_to_t[a] != b:
            return False
        if b in t_to_s and t_to_s[b] != a:
            return False
        s_to_t[a] = b
        t_to_s[b] = a
    return True

Pattern clue: "rename / relabel consistently" → two maps, one per direction, checked every step.

29.4 Group Anagrams

🟡 Easy+

Group words that are anagrams of each other.

["eat","tea","tan","ate","nat","bat"] → [["eat","tea","ate"], ["tan","nat"], ["bat"]]

Show Hint

Anagrams become identical when sorted. That sorted string is a perfect dictionary key.

Show Approach

For each word compute tuple(sorted(w)) as its signature and append the word to the group with that signature.

Show Solution
Python
def group_anagrams(words):
    groups = {}
    for w in words:
        key = tuple(sorted(w))    # anagrams share this signature
        if key not in groups:
            groups[key] = []
        groups[key].append(w)
    return list(groups.values())

Pattern clue: "group items that are equivalent up to rearrangement" → canonical signature as a dict key.

Two Pointers — position is information

Two moving indices replace nested loops whenever sortedness or position lets you discard candidates.

29.5 Squares of a Sorted Array

🟡 Easy+

nums is sorted (negatives allowed). Return the squares, also sorted.

[-4,-1,0,3,10] → [0,1,9,16,100]

Show Hint

The biggest square comes from one of the two ends — the most negative or the most positive value.

Show Approach

Two pointers at the ends; compare absolute values, fill the result array from the back with the larger square, move that pointer inward.

Show Solution
Python
def sorted_squares(nums):
    n = len(nums)
    result = [0] * n
    left, right = 0, n - 1
    for pos in range(n - 1, -1, -1):      # fill from the biggest slot down
        if abs(nums[left]) > abs(nums[right]):
            result[pos] = nums[left] ** 2
            left += 1
        else:
            result[pos] = nums[right] ** 2
            right -= 1
    return result

Pattern clue: "sorted input where the extremes matter" → two pointers at both ends.

29.6 Valid Palindrome After Deleting One

🟡 Easy+

Return True if s is a palindrome after deleting at most one character.

"abca" → True (delete 'b' or 'c') · "abc" → False

Show Hint

Walk inward as long as characters match. The first mismatch is your one chance to delete.

Show Approach

On mismatch, try skipping the left character OR the right character — if either remaining stretch is a palindrome, answer True.

Show Solution
Python
def valid_palindrome_after_delete(s):
    def is_pal(lo, hi):
        while lo < hi:
            if s[lo] != s[hi]:
                return False
            lo += 1
            hi -= 1
        return True

    left, right = 0, len(s) - 1
    while left < right:
        if s[left] != s[right]:
            # one free deletion: skip left OR skip right
            return is_pal(left + 1, right) or is_pal(left, right - 1)
        left += 1
        right -= 1
    return True

Pattern clue: "palindrome" + a single allowed exception → two pointers inward, one branch point.

29.7 Container With Most Water

🟠 Intermediate

Lines at indices 0..n−1 with heights height[i]; pick two lines forming a container holding the most water. Return that amount.

[1,8,6,2,5,4,8,3,7] → 49 (lines 8 and 7, width 7)

Show Hint

Start with the widest container. Shrinking it only helps if you remove the shorter wall.

Show Approach

Left and right pointers at the ends. Area = width × min(heights). Always move the pointer at the shorter wall — keeping it can never improve the area.

Show Solution
Python
def max_water(height):
    left, right = 0, len(height) - 1
    best = 0
    while left < right:
        width = right - left
        best = max(best, width * min(height[left], height[right]))
        # the shorter wall is the bottleneck — move it
        if height[left] < height[right]:
            left += 1
        else:
            right -= 1
    return best

Pattern clue: "max area between two positions" → opposite-end pointers, evict the limiting side each step.

29.8 Merge Sorted Array In Place

🟡 Easy+

nums1 has its m real values then n empty slots; merge nums2 into nums1 in place.

nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6] → [1,2,2,3,5,6]

Show Hint

The empty slots are at the end — so fill the end first, comparing from the backs.

Show Approach

Three indices: back of nums1's real values, back of nums2, write position at the very end. Place the larger remaining value at the write slot each step.

Show Solution
Python
def merge_sorted_array(nums1, m, nums2, n):
    i, j, write = m - 1, n - 1, m + n - 1
    while j >= 0:                 # until nums2 is fully placed
        if i >= 0 and nums1[i] > nums2[j]:
            nums1[write] = nums1[i]   # nums1's value is bigger
            i -= 1
        else:
            nums1[write] = nums2[j]
            j -= 1
        write -= 1
    return nums1

Pattern clue: "merge + in place + free space at the end" → run the merge backwards, largest first.

Sliding Window — contiguous stretches

Grow and shrink a window instead of re-summing; each element enters and leaves at most once.

29.9 Maximum Average Subarray of Size k

🟢 Easy

Find the highest average among all subarrays of exactly k elements.

[1,12,-5,-6,50,3], k = 4 → 12.75 (window [12,-5,-6,50], sum 51)

Show Hint

Same sum, same winner — maximize the window sum and divide once at the end.

Show Approach

Fixed-size window: sum the first k, then slide by adding the entering element and subtracting the leaving one.

Show Solution
Python
def find_max_average(nums, k):
    window = sum(nums[:k])
    best = window
    for right in range(k, len(nums)):
        window += nums[right] - nums[right - k]
        best = max(best, window)
    return best / k

Pattern clue: "subarray of size k" → fixed sliding window, O(n).

29.10 Longest Substring Without Repeating Characters

🟡 Easy+

Length of the longest substring with all distinct characters.

"abcabcbb" → 3 · "bbbbb" → 1 · "pwwkew" → 3

Show Hint

When the new character is already inside the window, you can jump left straight past its previous copy.

Show Approach

Keep a dict char → last index. If the char was seen at or after left, move left to one past it; update the answer every step.

Show Solution
Python
def length_of_longest_unique(s):
    last_seen = {}
    left = 0
    best = 0
    for right, ch in enumerate(s):
        if ch in last_seen and last_seen[ch] >= left:
            left = last_seen[ch] + 1   # jump past the previous copy
        last_seen[ch] = right
        best = max(best, right - left + 1)
    return best

Pattern clue: "longest substring with a validity rule" → variable window with a remembered last position.

29.11 Shortest Subarray With Sum at Least Target

🟡 Easy+

All values are positive. Find the length of the shortest subarray with sum ≥ target (0 if none).

target = 7, nums = [2,3,1,2,4,3] → 2 (the window [4,3])

Show Hint

Positivity is a gift: extending the window only grows the sum, so you can shrink the moment the sum is big enough.

Show Approach

Grow right; while the window sum ≥ target, record its length and shrink from left.

Show Solution
Python
def min_subarray_len(target, nums):
    left = 0
    window = 0
    best = float('inf')
    for right in range(len(nums)):
        window += nums[right]
        while window >= target:       # valid → try to shrink
            best = min(best, right - left + 1)
            window -= nums[left]
            left += 1
    return 0 if best == float('inf') else best

Pattern clue: "shortest contiguous stretch, positive numbers" → variable window, shrink while valid.

29.12 Max Consecutive Ones with k Flips

🟠 Intermediate

Binary array; you may flip at most k zeroes to ones. Longest run of ones possible?

[1,1,1,0,0,0,1,1,1,1,0], k = 2 → 6 (flip the zeroes at indices 5 and 10)

Show Hint

The window may contain at most k zeroes — that is the validity rule.

Show Approach

Count zeroes in the window; while the count exceeds k, shrink from the left (decrementing when a zero leaves). Track the max window length.

Show Solution
Python
def longest_ones(nums, k):
    left = 0
    zeros = 0
    best = 0
    for right, x in enumerate(nums):
        if x == 0:
            zeros += 1
        while zeros > k:              # budget exceeded → shrink
            if nums[left] == 0:
                zeros -= 1
            left += 1
        best = max(best, right - left + 1)
    return best

Pattern clue: "at most k changes inside a stretch" → window validity = a counter within budget.

Prefix Sum — pay once, query forever

Running totals turn every range question into a subtraction — and, with a map, into counting.

29.13 Range Sum Query — Immutable

🟢 Easy

Array is fixed; answer many queries "sum of nums[left..right]" instantly.

[-2,0,3,-5,2,-1]: sum(0,2) → 1 · sum(2,5) → -1

Show Hint

Build prefix[i] = sum of the first i elements once; each query is one subtraction.

Show Approach

Store prefix with a leading 0. Sum of left..right = prefix[right+1] − prefix[left].

Show Solution
Python
class NumArray:
    def __init__(self, nums):
        self.prefix = [0]
        for x in nums:
            self.prefix.append(self.prefix[-1] + x)

    def sum_range(self, left, right):
        return self.prefix[right + 1] - self.prefix[left]

Pattern clue: "many range-sum queries on a fixed array" → prefix array, O(1) per query.

29.14 Subarray Sum Equals K

🟡 Easy+

Count subarrays that sum to exactly k (values may be negative).

[1,1,1], k = 2 → 2

Show Hint

A subarray ending here sums to k iff an earlier running sum equaled running − k. How many times did it appear?

Show Approach

Keep a dict running-sum → frequency, seeded with {0: 1}. For each element add seen[running − k] to the count, then record the current running sum.

Show Solution
Python
def subarray_sum(nums, k):
    count = 0
    running = 0
    seen = {0: 1}                 # empty prefix has sum 0
    for x in nums:
        running += x
        count += seen.get(running - k, 0)   # subarrays ending right here
        seen[running] = seen.get(running, 0) + 1
    return count

Pattern clue: "count subarrays with a sum condition" + negatives → running sum + hash map.

29.15 Find Pivot Index

🟢 Easy

Return the index where the sum of everything strictly left equals the sum of everything strictly right (−1 if none).

[1,7,3,6,5,6] → 3 (left 1+7+3 = 11, right 5+6 = 11)

Show Hint

You only need the total once: right = total − left − nums[i].

Show Approach

Compute the total, then sweep left accumulating; at each index compare the accumulated sum with the right-hand remainder.

Show Solution
Python
def pivot_index(nums):
    total = sum(nums)
    left = 0
    for i, x in enumerate(nums):
        if left == total - left - x:   # left sum == right sum
            return i
        left += x
    return -1

Pattern clue: "left part vs right part of every position" → one total + running left sum.

29.16 Find the Highest Altitude

🟢 Easy

gain[i] is the net altitude change of leg i, starting at altitude 0. Highest altitude reached?

[-5,1,5,0,-7] → 1 (altitudes 0, −5, −4, 1, 1, −6)

Show Hint

The altitudes themselves are a running sum — you never need to store them all.

Show Approach

Accumulate each gain and keep the maximum seen (altitude 0 counts).

Show Solution
Python
def largest_altitude(gain):
    altitude = 0
    best = 0                        # the start point is altitude 0
    for g in gain:
        altitude += g
        best = max(best, altitude)
    return best

Pattern clue: "running total of changes" → prefix sum folded into one variable.

Sorting & Binary Search — halve the search space

Sort to make structure visible; then, on sorted or monotonic data, answer in O(log n).

29.17 Search Insert Position

🟢 Easy

Sorted, distinct array. Return the index of target, or the index where it would be inserted.

[1,3,5,6]: target 5 → 2 · target 2 → 1 · target 7 → 4

Show Hint

Standard binary search — where does lo end up when the target is absent?

Show Approach

Run the classic loop; on a miss, lo has stopped exactly at the first slot holding a bigger value — return it.

Show Solution
Python
def search_insert(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return lo                     # first slot with a bigger value

Pattern clue: "sorted + find position" → binary search; the miss case falls out for free.

29.18 Smallest Letter Greater Than Target

🟡 Easy+

Sorted list of lowercase letters, possibly with duplicates. Smallest letter strictly greater than target; wrap around to the first letter if none.

["c","f","j"]: target "a" → "c" · target "c" → "f" · target "z" → "c"

Show Hint

"Strictly greater" means even an equal letter keeps searching right.

Show Approach

Binary search where letters[mid] > target records a candidate and moves left; otherwise move right. Default answer is the first letter (wrap-around).

Show Solution
Python
def next_greatest_letter(letters, target):
    lo, hi = 0, len(letters) - 1
    ans = letters[0]              # wrap-around default
    while lo <= hi:
        mid = (lo + hi) // 2
        if letters[mid] > target:
            ans = letters[mid]    # candidate — try for a smaller one
            hi = mid - 1
        else:
            lo = mid + 1
    return ans

Pattern clue: "sorted + first element satisfying a comparison" → binary search that remembers a candidate.

29.19 Sort Characters By Frequency

🟡 Easy+

Rearrange a string so characters appear grouped, most frequent first.

"tree" → "eert" (or "eetr" — equal counts may order either way)

Show Hint

Count first, then sort by count — the string itself is never re-scanned.

Show Approach

Build a frequency dict, sort its characters by descending count, and append each character repeated by its count.

Show Solution
Python
def frequency_sort(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    chars = sorted(counts, key=lambda ch: -counts[ch])
    result = []
    for ch in chars:
        result.append(ch * counts[ch])
    return "".join(result)

Pattern clue: "order by frequency" → frequency map, then sort the (at most n) distinct keys.

29.20 Kth Largest Element in an Array

🟡 Easy+

Find the kth largest element without full sorting the array into the answer.

[3,2,1,5,6,4], k = 2 → 5

Show Hint

A min-heap of size k: the root is the kth largest the whole way through.

Show Approach

Push each value; whenever the heap exceeds size k, pop the smallest. The root is the answer. O(n log k) beats the O(n log n) sort.

Show Solution
Python
import heapq

def kth_largest(nums, k):
    heap = []
    for x in nums:
        heapq.heappush(heap, x)
        if len(heap) > k:
            heapq.heappop(heap)   # drop the smallest of the k+1 kept
    return heap[0]

Pattern clue: "kth largest / top k" → size-k min-heap (Part 17).

Stack & Queue — which end matters

Most-recent-first for matching and undo; first-in-first-out for fair, ordered processing.

29.21 Backspace String Compare

🟡 Easy+

'#' deletes the previous character. Do s and t end up equal?

"ab#c" vs "ad#c" → True · "a#c" vs "b" → False

Show Hint

Simulate typing: each '#' pops the last thing typed.

Show Approach

Build each string on a stack (append letters, pop on '#'), then compare the two stacks.

Show Solution
Python
def backspace_compare(s, t):
    def build(string):
        stack = []
        for ch in string:
            if ch != '#':
                stack.append(ch)
            elif stack:
                stack.pop()
        return stack

    return build(s) == build(t)

Pattern clue: "delete the previous item" → stack, the natural undo structure.

29.22 Baseball Game Score

🟢 Easy

Ops: an integer scores itself; "D" doubles the last score; "C" cancels the last; "+" scores the sum of the last two. Total?

["5","2","C","D","+"] → 30 (5; 5,2; 5; 5,10; 5,10,15 → 30)

Show Hint

Every operation touches the last score — that is the stack's whole job.

Show Approach

Process ops on a stack: push numbers, double the top for "D", pop for "C", push the sum of the top two for "+". Return the sum.

Show Solution
Python
def baseball_score(ops):
    stack = []
    for op in ops:
        if op == "+":
            stack.append(stack[-1] + stack[-2])
        elif op == "C":
            stack.pop()
        elif op == "D":
            stack.append(2 * stack[-1])
        else:
            stack.append(int(op))
    return sum(stack)

Pattern clue: "cancel the last / based on the last" → stack of records.

29.23 Implement a Queue with Two Stacks

🟡 Easy+

Support FIFO pop using only two LIFO stacks.

push 1, 2, 3 → pop() returns 1 → pop() returns 2

Show Hint

Pouring one stack onto another reverses the order — twice reversed is FIFO again.

Show Approach

Push onto inbox. To pop, refill outbox from inbox only when it is empty, then pop from outbox.

Show Solution
Python
class MyQueue:
    def __init__(self):
        self.inbox = []       # new items land here
        self.outbox = []      # pops leave from here

    def push(self, x):
        self.inbox.append(x)

    def pop(self):
        if not self.outbox:   # pour everything over, oldest on top
            while self.inbox:
                self.outbox.append(self.inbox.pop())
        return self.outbox.pop()

Pattern clue: "reverse the order twice" → two stacks amortized to O(1) per pop.

29.24 Daily Temperatures

🟠 Intermediate

For each day, how many days until a warmer one? (0 if never)

[73,74,75,71,69,72,76,73] → [1,1,4,2,1,1,0,0]

Show Hint

Keep a stack of days still waiting for warmth. A warmer day resolves them — which ones?

Show Approach

Push each day's index. Before pushing, pop every colder waiting day: its answer is the current index minus its own.

Show Solution
Python
def daily_temperatures(temps):
    answer = [0] * len(temps)
    stack = []                # indices of days still waiting for warmth
    for i, t in enumerate(temps):
        while stack and temps[stack[-1]] < t:
            j = stack.pop()   # day j just found its warmer day: i
            answer[j] = i - j
        stack.append(i)
    return answer

Pattern clue: "next greater element for everyone" → stack of waiting indices, O(n) (Part 11).

Linked List — pointer surgery

All the work is in rewiring .next links; a dummy node absorbs every head special case.

29.25 Remove Linked List Elements

🟢 Easy

Remove all nodes with value val from a linked list.

1→2→6→3→4→5→6, val = 6 → 1→2→3→4→5

Show Hint

Watch the node before the one you may remove — the dummy node makes the head safe too.

Show Approach

Walk with curr starting at a dummy; if curr.next matches, unlink it and stay put (the new curr.next may match too); otherwise advance.

Show Solution
Python
def remove_elements(head, val):
    dummy = Node(0)       # dummy saves the "delete the head" cases
    dummy.next = head
    curr = dummy
    while curr.next:
        if curr.next.val == val:
            curr.next = curr.next.next    # unlink; do NOT advance
        else:
            curr = curr.next
    return dummy.next

Pattern clue: "unlink nodes while walking" → dummy head + previous-pointer traversal (Part 13).

29.26 Remove Duplicates from a Sorted List

🟢 Easy

Sorted list; delete duplicates so each value appears once.

1→1→2→3→3 → 1→2→3

Show Hint

Duplicates are adjacent — only curr and curr.next ever need comparing.

Show Approach

If curr.val == curr.next.val, skip the next node; otherwise advance. Sortedness does the rest.

Show Solution
Python
def delete_duplicates(head):
    curr = head
    while curr and curr.next:
        if curr.val == curr.next.val:
            curr.next = curr.next.next    # skip the duplicate
        else:
            curr = curr.next
    return head

Pattern clue: "sorted list + adjacent duplicates" → single walk, skip on equality.

29.27 Add Two Numbers as Lists

🟠 Intermediate

Two numbers are stored digit-by-digit, least significant first. Add them and return the sum as such a list.

(2→4→3) + (5→6→4) = 342 + 465 → 7→0→8

Show Hint

It is exactly grade-school addition: one carry variable, position by position.

Show Approach

Walk both lists with a carry. At each step total = carry + digit + digit; append total % 10, carry total // 10. Continue while either list or the carry remains.

Show Solution
Python
def add_two_numbers(l1, l2):
    dummy = Node(0)
    curr = dummy
    carry = 0
    while l1 or l2 or carry:
        total = carry
        if l1:
            total += l1.val
            l1 = l1.next
        if l2:
            total += l2.val
            l2 = l2.next
        curr.next = Node(total % 10)
        carry = total // 10
        curr = curr.next
    return dummy.next

Pattern clue: "digit lists, least significant first" → simulate column addition; the dummy collects the answer.

29.28 Intersection of Two Linked Lists

🟠 Intermediate

Two lists may share a tail from some node on. Find that node (or None) — O(1) space.

A = 4→1→[8→4→5], B = 5→0→1→[8→4→5] → node 8

Show Hint

Pointer 1 walks A then B; pointer 2 walks B then A. They have travelled the same total distance when they meet.

Show Approach

When a pointer falls off its list's end, restart it at the other list's head. If the lists intersect, both reach the junction together; if not, they meet at None.

Show Solution
Python
def get_intersection(headA, headB):
    p1, p2 = headA, headB
    while p1 is not p2:
        # switch lists at the end: each pointer travels a + b steps
        p1 = p1.next if p1 else headB
        p2 = p2.next if p2 else headA
    return p1     # the junction node, or None if the lists never meet

Pattern clue: "equalise two different-length walks with O(1) space" → swap heads at the ends.

Trees — recursion made visible

Solve the problem for the root assuming the subtrees already answer it — that is the whole trick.

29.29 Symmetric Tree

🟡 Easy+

Is a binary tree a mirror of itself around the root?

1 / (2,2) with children (3,4),(4,3) → True · with children (null,3),(null,3) → False

Show Hint

"Mirror" pairs a.left with b.right, not with b.left.

Show Approach

A tree is symmetric iff its root's two subtrees are mirrors: equal values, a.left mirrors b.right, a.right mirrors b.left.

Show Solution
Python
def is_symmetric(root):
    def mirror(a, b):
        if a is None and b is None:
            return True
        if a is None or b is None:
            return False
        return (a.val == b.val
                and mirror(a.left, b.right)
                and mirror(a.right, b.left))

    return mirror(root, root)

Pattern clue: "mirror / compare two subtrees" → recursion on node pairs, crossed children.

29.30 Diameter of a Binary Tree

🟡 Easy+

Length (in edges) of the longest path between any two nodes.

Tree 1 / 2,3 / 4,5 → 3 (4→2→1→3)

Show Hint

The longest path through a node is left-depth + right-depth. Compute depths bottom-up and remember the best.

Show Approach

A depth function returns subtree height; on the way back up it updates a "best" variable with left + right for each node.

Show Solution
Python
def diameter_of_binary_tree(root):
    best = 0                     # longest edge count seen so far

    def depth(node):
        nonlocal best
        if node is None:
            return 0
        left = depth(node.left)
        right = depth(node.right)
        best = max(best, left + right)   # path through this node
        return 1 + max(left, right)

    depth(root)
    return best

Pattern clue: "longest path in a tree" → postorder: children first, combine at the parent.

29.31 Balanced Binary Tree

🟡 Easy+

Every node's two subtree heights differ by at most 1 — is the tree balanced?

3 / 9,20 / 15,7 → True · the " comb" 1 / 2 / 2 / 3 / 3 / 4,4 shape → False

Show Hint

Height computation and the balance check are the same bottom-up pass. Use −1 as a "failed" signal.

Show Approach

check returns the height, or −1 if any subtree below is unbalanced. A node is unbalanced if either child reported −1 or the heights differ by more than 1.

Show Solution
Python
def is_balanced(root):
    def check(node):
        if node is None:
            return 0                   # height of an empty subtree
        left = check(node.left)
        right = check(node.right)
        if left == -1 or right == -1 or abs(left - right) > 1:
            return -1                  # -1 = "unbalanced somewhere below"
        return 1 + max(left, right)

    return check(root) != -1

Pattern clue: "property of every subtree" → one postorder pass returning height or a failure flag.

29.32 Minimum Difference Between BST Nodes

🟡 Easy+

In a BST, find the smallest difference between the values of any two different nodes.

BST 4 / 2,6 / 1,3 → 1 (inorder 1,2,3,4,6)

Show Hint

BST inorder traversal reads out sorted values — then it is just adjacent differences.

Show Approach

Iterative inorder with a stack; track the previous value visited and update the min gap against the current value.

Show Solution
Python
def min_diff_in_bst(root):
    stack = []
    curr = root
    prev = None
    best = float('inf')
    while curr or stack:
        while curr:               # slide left, remembering the path
            stack.append(curr)
            curr = curr.left
        curr = stack.pop()
        if prev is not None:
            best = min(best, curr.val - prev)
        prev = curr.val
        curr = curr.right
    return best

Pattern clue: "BST + order-based question" → inorder gives sorted order (Part 16).

Heap — always need the current best?

A size-k heap keeps only the k candidates that can still matter. Everything else is evicted.

29.33 Last Stone Weight

🟢 Easy

Repeatedly smash the two heaviest stones: they break if equal, otherwise a stone of the difference remains. Last weight (or 0)?

[2,7,4,1,8,1] → 1

Show Hint

Python's heap is a min-heap — negate every weight to simulate a max-heap.

Show Approach

Heapify the negatives. Pop two, push the negated difference if they differ. One stone left → its weight; none → 0.

Show Solution
Python
import heapq

def last_stone_weight(stones):
    heap = [-s for s in stones]   # negatives: min-heap acts as max-heap
    heapq.heapify(heap)
    while len(heap) > 1:
        heavy = -heapq.heappop(heap)
        second = -heapq.heappop(heap)
        if heavy != second:
            heapq.heappush(heap, -(heavy - second))
    return -heap[0] if heap else 0

Pattern clue: "repeatedly take the largest" → max-heap via negation.

29.34 K Closest Points to the Origin

🟡 Easy+

Return the k points closest to (0,0) (distance uses squared values — no sqrt needed).

[[1,3],[-2,2]], k = 1 → [[-2,2]]

Show Hint

Size-k heap again — but this time you must evict the farthest, so store negated distances.

Show Approach

Keep a min-heap of size k of negated distances; a newcomer enters only if it is closer than the current worst. O(n log k).

Show Solution
Python
import heapq

def k_closest(points, k):
    heap = []                     # max-heap of size k via negative distance
    for x, y in points:
        dist = -(x * x + y * y)   # negate: the farthest = smallest key
        if len(heap) < k:
            heapq.heappush(heap, (dist, x, y))
        elif dist > heap[0][0]:
            heapq.heapreplace(heap, (dist, x, y))
    return [[x, y] for (_, x, y) in heap]

Pattern clue: "k best of many candidates" → size-k heap holding the current winners.

29.35 Take Gifts From the Richest Pile

🟡 Easy+

For k seconds, take the biggest pile and leave behind its floor-square-root. Sum what remains.

[25,64,9,4,100], k = 4 → 29 (100→10, 64→8, 25→5, 10→3; left: 5+8+9+4+3)

Show Hint

Each second is an independent "extract the max" operation.

Show Approach

Negate into a max-heap; k times pop the biggest and push its isqrt back (negated). Negate-and-sum what is left.

Show Solution
Python
import heapq
import math

def pick_gifts(gifts, k):
    heap = [-g for g in gifts]
    heapq.heapify(heap)
    for _ in range(k):
        biggest = -heapq.heappop(heap)
        heapq.heappush(heap, -math.isqrt(biggest))   # leave floor(sqrt)
    return -sum(heap)

Pattern clue: "repeatedly take the richest/biggest" → max-heap by negation.

29.36 Kth Largest Element in a Stream

🟡 Easy+

Design a class: initialise with nums, then values arrive one by one; after each, report the kth largest.

KthLargest(3, [4,5,8,2]): add(3)→4, add(5)→5, add(10)→5, add(9)→8

Show Hint

Never keep more than k values — the smallest of those k is exactly the kth largest.

Show Approach

Push each new value; if the heap grows past k, pop the smallest. heap[0] is the running kth largest.

Show Solution
Python
import heapq

class KthLargest:
    def __init__(self, k, nums):
        self.k = k
        self.heap = []
        for x in nums:
            self.add(x)

    def add(self, val):
        heapq.heappush(self.heap, val)
        if len(self.heap) > self.k:
            heapq.heappop(self.heap)   # keep only the k largest
        return self.heap[0]            # smallest of the k largest

Pattern clue: "running kth largest as data arrives" → persistent size-k min-heap.

Graphs BFS / DFS — everything reachable

Grids are graphs. Visited sets are not optional — they are what stops the infinite loops.

29.37 Find if a Path Exists

🟢 Easy

Given n nodes (0..n−1) and undirected edges, is there a path from source to destination?

n = 6, edges = [[0,1],[0,2],[3,5],[5,4],[4,3]], 0 → 5 → False · 0 → 2 → True

Show Hint

Build the adjacency list, then a plain BFS from the source answers everything reachable.

Show Approach

BFS from source over the adjacency dict; return True the moment the destination pops out of the queue.

Show Solution
Python
from collections import deque

def valid_path(n, edges, source, destination):
    graph = {i: [] for i in range(n)}
    for a, b in edges:
        graph[a].append(b)
        graph[b].append(a)

    visited = {source}
    q = deque([source])
    while q:
        node = q.popleft()
        if node == destination:
            return True
        for nxt in graph[node]:
            if nxt not in visited:
                visited.add(nxt)
                q.append(nxt)
    return False

Pattern clue: "is X reachable from Y (unweighted)?" → BFS (or DFS) with a visited set.

29.38 Flood Fill

🟢 Easy

Recolour every cell connected (4-directionally) to (sr, sc) that shares its starting colour.

[[1,1,1],[1,1,0],[1,0,1]], start (1,1), new 2 → [[2,2,2],[2,2,0],[2,0,1]]

Show Hint

Guard first: if the old colour already equals the new one, doing nothing avoids an infinite loop.

Show Approach

DFS from the start cell: repaint the cell, then recurse into the 4 neighbours that are inside the grid and still the old colour.

Show Solution
Python
def flood_fill(image, sr, sc, new_color):
    rows, cols = len(image), len(image[0])
    old_color = image[sr][sc]
    if old_color == new_color:
        return image              # nothing to do — avoids an infinite loop

    def dfs(r, c):
        if image[r][c] != old_color:
            return
        image[r][c] = new_color   # repaint = mark visited
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols:
                dfs(nr, nc)

    dfs(sr, sc)
    return image

Pattern clue: "spread from a starting cell to everything connected" → grid DFS/BFS.

29.39 Rotting Oranges

🟠 Intermediate

0 = empty, 1 = fresh, 2 = rotten. Each minute rotten spreads to 4-neighbours. Minutes until all fresh rot (−1 if impossible)?

[[2,1,1],[1,1,0],[0,1,1]] → 4

Show Hint

All rotten oranges spread simultaneously — seed the queue with every rotten cell, one BFS level per minute.

Show Approach

Multi-source BFS: count fresh, enqueue all rotten. Each level = one minute; rot neighbours as you pop. Leftover fresh → −1.

Show Solution
Python
from collections import deque

def oranges_rotting(grid):
    rows, cols = len(grid), len(grid[0])
    q = deque()
    fresh = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 2:
                q.append((r, c))
            elif grid[r][c] == 1:
                fresh += 1

    minutes = 0
    while q and fresh > 0:
        minutes += 1
        for _ in range(len(q)):       # one whole level = one minute
            r, c = q.popleft()
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                    grid[nr][nc] = 2  # rot it (also marks it visited)
                    fresh -= 1
                    q.append((nr, nc))
    return minutes if fresh == 0 else -1

Pattern clue: "spreads simultaneously from many sources, count the rounds" → multi-source BFS with level counting.

29.40 Max Area of Island

🟡 Easy+

Grid of 1s (land) and 0s (water); find the largest island's cell count (4-directional).

[[1,1,0],[0,1,0],[1,0,1]] → 3

Show Hint

DFS from every land cell, returning the island's size — and sink the cells you count so they are never recounted.

Show Approach

dfs(r,c) returns 0 for water/out-of-bounds; otherwise it zeroes the cell and returns 1 plus the four recursive sizes. Sweep the grid for the max.

Show Solution
Python
def max_area_of_island(grid):
    rows, cols = len(grid), len(grid[0])

    def dfs(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != 1:
            return 0
        grid[r][c] = 0              # sink the land = mark visited
        return (1 + dfs(r + 1, c) + dfs(r - 1, c)
                  + dfs(r, c + 1) + dfs(r, c - 1))

    best = 0
    for r in range(rows):
        for c in range(cols):
            best = max(best, dfs(r, c))
    return best

Pattern clue: "connected region of cells" → DFS from each unvisited cell, aggregate a counter.

Greedy & Intervals — sort first, then choose

Sorting creates the order in which the local best choice is also globally safe. Sanity-check with a tiny case.

29.41 Lemonade Change

🟡 Easy+

Customers pay with 5, 10 or 20 (lemonade costs 5). Serve everyone in order with correct change?

[5,5,10,20] → True · [5,5,10,10,20] → False

Show Hint

For a 20, giving a 10+5 keeps your fives — which only 10s and 20s can ever need.

Show Approach

Track counts of 5s and 10s. 10 → give a five. 20 → give 10+5 if possible, else 5+5+5, else fail.

Show Solution
Python
def lemonade_change(bills):
    fives = tens = 0
    for bill in bills:
        if bill == 5:
            fives += 1
        elif bill == 10:
            if fives == 0:
                return False
            fives -= 1
            tens += 1
        else:                        # a 20 needs 15 in change
            if tens > 0 and fives > 0:
                tens -= 1
                fives -= 1
            elif fives >= 3:
                fives -= 3
            else:
                return False
    return True

Pattern clue: "serve in order, limited change" → greedy over a fixed sequence, track resources.

29.42 Buy and Sell Stock — Unlimited Trades

🟢 Easy

You may buy and sell any number of times (one share held at a time). Maximum profit?

[7,1,5,3,6,4] → 7 ((5−1) + (6−3))

Show Hint

Every upward step between consecutive days is collectable profit.

Show Approach

Sweep once: add every positive difference prices[i] − prices[i−1]. Equivalent to selling before every local peak.

Show Solution
Python
def max_profit_unlimited(prices):
    profit = 0
    for i in range(1, len(prices)):
        if prices[i] > prices[i - 1]:
            profit += prices[i] - prices[i - 1]   # collect every rise
    return profit

Pattern clue: "unlimited transactions, max profit" → greedy sum of all positive deltas.

29.43 Car Pooling

🟡 Easy+

Trips are [passengers, start, end] along one road. Can a car of the given capacity serve them all?

[[2,1,5],[3,3,7]], capacity 4 → False · capacity 5 → True

Show Hint

Only the passenger count changes at stops — record deltas, then sweep the road in order.

Show Approach

Build a dict stop → delta (+ at boarding, − at dropping). Sweep the sorted stops adding up the onboard count; exceed the capacity anywhere → False.

Show Solution
Python
def car_pooling(trips, capacity):
    # sweep: +passengers where they board, -passengers where they leave
    changes = {}
    for num, start, end in trips:
        changes[start] = changes.get(start, 0) + num
        changes[end] = changes.get(end, 0) - num

    onboard = 0
    for stop in sorted(changes):      # process stops in road order
        onboard += changes[stop]
        if onboard > capacity:
            return False
    return True

Pattern clue: "load on a line, only endpoints matter" → difference sweep over sorted stops.

29.44 Minimum Number of Platforms

🟠 Intermediate

Trains arrive and depart; a train needs a platform from arrival until just after its departure. Minimum platforms?

arr = [900,940,950,1100,1500,1800], dep = [910,1200,1120,1130,1900,2000] → 3

Show Hint

Sort arrivals and departures independently, then walk both like two queues: the earlier event of the two wins.

Show Approach

Advance through arrivals: if the next arrival is not after the earliest still-present departure, one more platform is occupied; otherwise one frees. Track the peak.

Show Solution
Python
def min_platforms(arrivals, departures):
    arrivals.sort()
    departures.sort()
    platforms = 0
    best = 0
    i = j = 0
    while i < len(arrivals):
        if arrivals[i] <= departures[j]:  # arrives before the earliest leaves
            platforms += 1
            best = max(best, platforms)
            i += 1
        else:                             # a platform frees up
            platforms -= 1
            j += 1
    return best

Pattern clue: "overlapping time spans, count the peak" → sort both endpoints, sweep with a counter (Part 20).

Bits & Math — small tricks, big speedups

A handful of bit idioms and simulation patterns cover a surprising number of "math-y" problems.

29.45 Number of 1 Bits

🟢 Easy

Count the set bits in a positive integer's binary form.

11 = 1011 → 3

Show Hint

n & (n − 1) erases the lowest set bit. Count how many times you can do it.

Show Approach

Loop while n is nonzero, clearing the lowest set bit each round and counting the rounds.

Show Solution
Python
def count_one_bits(n):
    count = 0
    while n:
        n = n & (n - 1)   # clears the lowest set bit
        count += 1
    return count

Pattern clue: "count / manipulate set bits" → the n & (n−1) idiom, one pass per set bit.

29.46 Counting Bits

🟡 Easy+

For every i in 0..n, the number of 1s in i's binary form — in O(n).

n = 5 → [0,1,1,2,1,2]

Show Hint

i is i >> 1 shifted left plus one possible trailing 1.

Show Approach

DP over the array: bits[i] = bits[i >> 1] + (i & 1). Every answer reuses an earlier one.

Show Solution
Python
def count_bits(n):
    bits = [0] * (n + 1)
    for i in range(1, n + 1):
        bits[i] = bits[i >> 1] + (i & 1)   # halves it, plus the last bit
    return bits

Pattern clue: "answers for 0..n built from smaller answers" → bottom-up DP over the index itself.

29.47 Roman to Integer

🟡 Easy+

Convert a Roman numeral to an integer (subtractive pairs like IV, IX, XL appear).

"MCMXCIV" → 1994

Show Hint

A smaller value before a bigger one is subtracted; everything else is added.

Show Approach

Value map for the seven letters; scan left to right and subtract when the current value is smaller than the next.

Show Solution
Python
def roman_to_int(s):
    values = {'I': 1, 'V': 5, 'X': 10, 'L': 50,
              'C': 100, 'D': 500, 'M': 1000}
    total = 0
    for i, ch in enumerate(s):
        if i + 1 < len(s) and values[ch] < values[s[i + 1]]:
            total -= values[ch]    # smaller before larger → subtract
        else:
            total += values[ch]
    return total

Pattern clue: "symbol system with special local rules" → value map + one left-to-right pass.

29.48 Happy Number

🟡 Easy+

Replace n with the sum of the squares of its digits, forever. Does it reach 1?

19 → 1²+9²=82 → 68 → 100 → 1 → True

Show Hint

Every chain either hits 1 or loops — a set detects the loop, exactly like cycle detection.

Show Approach

Digit-square helper, then iterate while recording seen values; hit 1 → True, revisit a value → False.

Show Solution
Python
def is_happy(n):
    def next_number(x):
        total = 0
        while x > 0:
            digit = x % 10
            total += digit * digit
            x //= 10
        return total

    seen = set()
    while n != 1 and n not in seen:
        seen.add(n)
        n = next_number(n)
    return n == 1

Pattern clue: "iterate a rule until it repeats or reaches a target" → simulation + seen set.


Tip — read the clue, not just the code

After checking each solution, cover it and restate the Pattern clue line from memory. The code you can always re-derive; the recognition reflex is what this library is actually training.

What You Learned

  • 48 additional problems in quick-fire format, spanning all 12 pattern families.
  • The reveal ladder: attempt → hint → approach → solution — and stopping at each rung.
  • How the same skeleton (dict of seen things, size-k heap, sweep over sorted endpoints) reappears across totally different stories.
  • That a correct ≤ 18-line solution exists for every one of these — length is not the hard part, recognition is.

Pattern Recognition

Sort your misses, not your wins. Any card where you opened Show Hint before saying an approach out loud belongs in a personal "redo pile" — the groups here are deliberately arranged so you can re-drill exactly the family that failed. Aim to name the pattern within five seconds of reading a statement; Part 31 is the timed version of that test.

Complexity Summary

Library groupTypical optimal timeTypical extra space
HashingO(n)O(n)
Two PointersO(n)O(1)
Sliding WindowO(n)O(k)
Prefix SumO(n) build, O(1)/O(1)-per-query answersO(n)
Sorting & Binary SearchO(n log n) / O(log n)O(1)–O(n)
Stack & QueueO(n)O(n)
Linked ListO(n)O(1)
TreesO(n)O(h)
HeapO(n log k)O(k)
Graphs BFS/DFSO(V + E) = O(rows × cols) on gridsO(V)
Greedy & IntervalsO(n log n)O(1)–O(n)
Bits & MathO(log n)–O(n)O(1)–O(n)

Common Mistakes

  • Reading the solution before making any attempt — the card teaches nothing that way.
  • Using a sliding window when values can be negative (see 29.14): window sums are no longer monotonic.
  • Max-heap problems in Python without negating values — heap[0] is the minimum.
  • Graph problems with no visited set, or marking visited only after popping (duplicates in the queue).
  • Forgetting the {0: 1} seed in running-sum counting (it represents the empty prefix).
  • On intervals, comparing with the wrong convention for touching endpoints (see Part 20).

Mini Quiz

Q1. "Count subarrays summing to k" — why does the sliding window fail when negatives are allowed?
The window method relies on "grow = sum increases". Negative values break that, so the running-sum + hash map (prefix) method takes over.
Q2. Daily Temperatures (29.24) stores indices on its stack rather than temperatures because…
Each popped day j gets answer[i] = i − j; keeping the index is what makes that arithmetic possible.
Q3. 29.34 (k closest points) keeps negated distances in its size-k heap because…
To keep "the k best", a min-heap must evict the worst of them — negation turns the worst (largest distance) into the heap top.
Q4. Rotting Oranges (29.39) is which flavour of BFS?
Seeding the queue with all sources at once makes each BFS level exactly one minute of simultaneous spread.

Practice Problems

Practice — Three Fresh Cards

🟢 L1

Pick any 3 cards you have not solved from three different groups above. Attempt each with the full reveal ladder before checking anything.

Show Hint

Choose one group you feel strong in and two you do not — the library is arranged for exactly that mix.

Show Approach

Say the approach out loud in one sentence per card; write brute force first if the optimal idea does not come in two minutes.

Show Solution

Compare your attempt to the card's solution and note which reveal you needed — that is your score for the card.

Pattern clue: volume across varied groups beats depth in one favourite group.

Practice — The Redo Pile

🟡 L2

Return tomorrow and re-solve, from memory, every card where you needed the Show Approach reveal or beyond. Cover the code while re-attempting.

Show Hint

The common mistakes list at the end of this chapter is a checklist of exactly where those re-attempts will slip.

Show Approach

Re-derive the solution from the pattern clue line only; open the code just to verify.

Show Solution

A card "sticks" when you can write its solution and its clue line without a single reveal.

Pattern clue: spaced re-attempts convert recognition into recall.

Practice — Template Autopsy

🟡 L2

Take cards 29.8, 29.27 and 29.30. For each, name which Part 28 template it descends from, and which single line you would have to change.

Show Hint

Look for the read/write pointer, the dummy node, and the postorder combination.

Show Approach

29.8 is two pointers running backwards; 29.27 is list traversal + a carry variable; 29.30 is postorder with a best-so-far side channel.

Show Solution

The changed lines are the comparison/eviction line, the total % 10 append line, and the best = max(best, left + right) line respectively.

Pattern clue: every new solution is an old template with one mutated line.

Challenge Problem

Challenge — Count Subarrays Divisible by k

🟠 L3 · Recognize
Problem

Count the non-empty subarrays whose sum is divisible by k (k is a positive integer; values may be negative). n up to 200,000.

Input / Output

[4,5,0,-2,-3,1], k = 5 → 7

Show Hint

29.14 counted subarrays with an exact sum. What does "divisible by k" look like in running-sum language?

Show Approach

Track the running sum modulo k in a frequency map seeded with {0: 1}; two equal remainders mean the stretch between them sums to a multiple of k. Add seen[running % k] at each step.

Show Solution
Python
def subarrays_div_by_k(nums, k):
    seen = {0: 1}            # remainder -> how often seen (0 = empty prefix)
    running = 0
    count = 0
    for x in nums:
        running = (running + x) % k
        count += seen.get(running, 0)   # earlier prefixes with same remainder
        seen[running] = seen.get(running, 0) + 1
    return count
Pattern Recognition
Pattern Insight

Same skeleton as 29.14 with one mutation: the map keys are running % k instead of the raw running sum. Recognizing "this is that problem, one line changed" is the skill.

Revision Checklist

PART 30

Practice Mode

A self-graded worksheet: eight exercise types, every answer one reveal away.

Reading builds understanding; testing builds skill. This worksheet mixes eight exercise types — concept checks, output prediction, bug hunting, complexity, tracing, code completion, pattern naming, and full problems — using only 🟢 Easy, 🟡 Easy+ and 🟠 Beginner Intermediate material. Work top to bottom, or jump to the type that scares you most.

Rules of the game

Write or say an answer before opening any reveal. An answer you check is worth half of an answer you produced. If you miss an item, mark it and return after finishing the whole worksheet — the scoring table at the end tells you how to grade honestly.

Section A — Multiple-Choice Concepts 🟢

A1. You repeatedly need to ask "have I seen this value before?" over a long stream. The right structure is…
List membership is O(n) per check. A set (or dict) hashes the value, so each "have I seen it?" costs O(1) on average.
A2. After running nums = nums.sort(), what is nums?
Mutating methods like sort() return None. The one-liner nums = sorted(nums) is the safe alternative.
A3. Why does BFS use collections.deque instead of list.pop(0)?
Removing the front of a Python list copies the rest of the list one slot left. Over a whole BFS that turns an O(n) pass into O(n^2).
A4. Why does the variable sliding window stay O(n) even though it has a loop inside a loop?
Work is counted per pointer, not per iteration: right advances n times total, and left can never retreat — so at most 2n pointer movements overall.
A5. A recursive function forgets its base case. What happens on the first call?
Each call adds a stack frame; with no base case the calls never stop and Python aborts around the ~1000-frame depth limit.

Section B — Predict the Output 🟢 🟡

Read the code, decide the exact printed output, then answer.

Python
nums = [4, 1, 3, 2]
nums.append(5)
nums.sort()
print(nums[1])
B1. What is printed?
After append the list is [4, 1, 3, 2, 5]; sorting gives [1, 2, 3, 4, 5]. Index 1 holds 2 — append adds at the end, sort rearranges everything.
Python
s = "abc"
t = s.upper()
print(s, t)
B2. What is printed?
Strings are immutable: upper() returns a new string and never touches s. If you expected ABC ABC, reread the immutability rules in Part 4.
Python
stack = []
stack.append(1)
stack.append(2)
stack.append(3)
x = stack.pop()
y = stack.pop()
print(x * y)
B3. What is printed?
A stack pops most-recent-first: x = 3, then y = 2, so x * y = 6. First-in-first-out order would have given 2.
Python
from collections import deque

q = deque([10, 20, 30])
q.append(40)
first = q.popleft()
q.append(first)
print(list(q))
B4. What is printed?
append(40) → [10,20,30,40]; popleft removes the 10; appending it sends it to the back. This is exactly a left-rotation of the queue.
Python
freq = {"a": 2}
print(freq.get("b", 0) + freq.get("a", 0))
B5. What is printed?
get returns the default (0) for the missing key "b" and 2 for "a" — no exception, total 2. This idiom is the frequency-map shortcut.

Section C — Find the Bug 🟡

Each snippet looks reasonable and passes some tests. Find the failing line, then reveal the fix.

Python
# Goal: count the even numbers in nums
def count_even(nums):
    count = 0
    for i in range(len(nums)):
        if nums[i] % 2 = 0:
            count += 1
    return count
Reveal the bug and the fix

The bug: line 4 — = is assignment, comparison needs ==. Python refuses to run this at all (SyntaxError).

Python
def count_even(nums):
    count = 0
    for i in range(len(nums)):
        if nums[i] % 2 == 0:      # == for comparison
            count += 1
    return count
Python
# Goal: return the largest value in nums
def find_max(nums):
    best = 0
    for x in nums:
        if x > best:
            best = x
    return best
Reveal the bug and the fix

The bug: line 3 — starting from 0 assumes all values are positive. find_max([-5, -2, -9]) wrongly returns 0. Initialize from the data instead.

Python
def find_max(nums):
    best = nums[0]            # a real element, not a guess
    for x in nums:
        if x > best:
            best = x
    return best
Python
# Goal: classic binary search
def binary_search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo < hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1
Reveal the bug and the fix

The bug: line 3 — while lo < hi quits while a one-element search space is still alive. On binary_search([5], 5) the loop body never runs and it returns −1 for a value that is present. The convention pair is lo <= hi with hi = mid − 1 (see Part 10).

Python
def binary_search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:               # the single changed character
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1
Python
# Goal: remove every occurrence of val from nums
def remove_value(nums, val):
    for x in nums:
        if x == val:
            nums.remove(x)
    return nums
Reveal the bug and the fix

The bug: the loop mutates the list while iterating it. On [1, 2, 2, 3] with val 2, removing the first 2 shifts the second 2 into the position the iterator has already passed — the result is [1, 2, 3], one 2 short. Either build a new list or loop until stable.

Python
def remove_value(nums, val):
    result = []
    for x in nums:
        if x != val:
            result.append(x)
    return result
    # or, in place: while val in nums: nums.remove(val)

Section D — Complexity Check 🟡

D1. Building a frequency map of n items with one pass is…
n items, O(1) dict work each — one linear pass.
D2. Finding the kth largest with a size-k min-heap over n elements costs…
n pushes/pops into a heap that never exceeds size k; each heap op is O(log k). When k is much smaller than n this beats a full sort's O(n log n).
D3. Checking every pair of elements with two nested loops over n items is…
n outer iterations × (up to) n inner ones — the brute-force pair scan that hashing or two pointers usually replaces.
D4. A prefix array is built in O(n); answering q range-sum queries afterwards costs…
Each query is one subtraction — O(1). Total = build O(n) + q × O(1). This is the "pay once, query forever" trade.

Section E — Trace the Algorithm 🟡 🟠

Run the code by hand on the given input. Write every step down before revealing the full dry run.

E1 — Binary Search

Python
nums = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
# search for target = 23 with:
target = 23
lo, hi = 0, len(nums) - 1
while lo <= hi:
    mid = (lo + hi) // 2
    if nums[mid] == target:
        found = mid
        break
    elif nums[mid] < target:
        lo = mid + 1
    else:
        hi = mid - 1
Reveal the full dry run
Dry Run — target = 23
Step 1: lo=0, hi=9 → mid=4 → nums[4]=16 < 23 → lo = 5
Step 2: lo=5, hi=9 → mid=7 → nums[7]=56 > 23 → hi = 6
Step 3: lo=5, hi=6 → mid=5 → nums[5]=23 == target → found at index 5 ✅

Three probes for ten elements — and about 30 probes would settle a million. That is log₂(n).

E2 — Kadane's Extend-or-Restart

Python
nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
end_here = nums[0]
best = nums[0]
for x in nums[1:]:
    end_here = max(x, end_here + x)   # extend the run, or restart at x
    best = max(best, end_here)
print(best)
Reveal the full dry run
Dry Run — best subarray sum
x=-2: end_here=-2, best=-2
x= 1: end_here=max(1, -2+1)=1,  best=1
      (restart: extending a negative run would only hurt)
x=-3: end_here=max(-3, 1-3)=-2, best=1
x= 4: end_here=max(4, -2+4)=4,  best=4
x=-1: end_here=max(-1, 4-1)=3,  best=4
x= 2: end_here=max(2, 3+2)=5,   best=5
x= 1: end_here=max(1, 5+1)=6,   best=6
x=-5: end_here=max(-5, 6-5)=1,  best=6
x= 4: end_here=max(4, 1+4)=5,   best=6
output: 6  ✅  (the subarray [4, -1, 2, 1])

Read each line as: "keep the running stretch, or drop it and start fresh at x — whichever is larger." The clean version of this trace, with the line corrected as shown, is exactly the algorithm from Part 3.

E3 — Bracket Matching on a Stack

Python
s = "({[]})"
stack = []
for ch in s:
    if ch in "([{":
        stack.append(ch)
    else:
        stack.pop()   # assume it always matches, for this trace
print(len(stack) == 0)
Reveal the full dry run
Dry Run — stack contents, char by char
'('  → push  → ['(']
'{'  → push  → ['(', '{']
'['  → push  → ['(', '{', '[']
']'  → pop '[' → ['(', '{']
')'  → pop '{' → ['(']
'}'  → pop '(' → []
output: True ✅ (stack empty = every opener matched)

The simplified version above pops blindly; the real checker from Part 11 also verifies which bracket pops and fails safely on an empty stack — try "([)]" on the full version and watch it return False at the third character.

Section F — Fill the Missing Code 🟡 🟠

Each function is one small step away from working. Decide your lines, then compare.

Python
def char_frequency(s):
    freq = {}
    for ch in s:
        # TODO: count ch in freq correctly
        ...
    return freq
Reveal the missing lines
Python
def char_frequency(s):
    freq = {}
    for ch in s:
        if ch in freq:
            freq[ch] += 1
        else:
            freq[ch] = 1
    return freq
    # one-line alternative: freq[ch] = freq.get(ch, 0) + 1

char_frequency("aab") → {'a': 2, 'b': 1}. The get-default version is the same idea compressed.

Python
# nums is sorted; return the indices of a pair summing to target
def pair_sum(nums, target):
    left, right = 0, len(nums) - 1
    while left < right:
        s = nums[left] + nums[right]
        if s == target:
            return (left, right)
        # TODO: move one pointer so the sum moves toward the target
        ...
    return None
Reveal the missing lines
Python
        elif s < target:
            left += 1     # sum too small → need a bigger left value
        else:
            right -= 1    # sum too big → need a smaller right value

The direction of the move is dictated by the sorted array — that single elif/else pair is the entire algorithm.

Python
def reverse_string(s):
    stack = []
    for ch in s:
        stack.append(ch)
    result = ""
    # TODO: pop everything off the stack to build the reversed string
    ...
    return result
Reveal the missing lines
Python
    while stack:
        result += stack.pop()

reverse_string("abc") → "cba". Pushing preserves order; popping reverses it — the stack is the reversal.

Python
from collections import deque

def bfs(graph, start):
    # graph looks like {'A': ['B', 'C'], 'B': [], 'C': []}
    visited = {start}
    q = deque([start])
    order = []
    while q:
        node = q.popleft()
        order.append(node)
        # TODO: enqueue every unvisited neighbour of node
        ...
    return order
Reveal the missing lines
Python
        for nxt in graph[node]:
            if nxt not in visited:
                visited.add(nxt)   # mark BEFORE enqueueing
                q.append(nxt)

On {'A': ['B','C'], 'B': [], 'C': []} from 'A' the order is ['A', 'B', 'C']. Marking inside the loop (not when popping) is what keeps duplicates out of the queue.

Section G — Name the Pattern 🟡 🟠

One sentence of problem, one pattern. Do not open the explanation until you have committed.

G1. "A sorted array; find two values that add up to a target, using O(1) extra space."
Sorted + pair + O(1) space is the exact two-pointer fingerprint. (Hashing would solve it too — but needs O(n) space, which the statement forbids.)
G2. "Check whether nested tags in an HTML document are all properly closed."
"Most recently opened must close first" is the stack contract — push opens, match-and-pop closes.
G3. "Data keeps arriving; after each arrival you must report the k largest values seen so far."
A persistent size-k heap answers each arrival in O(log k) and always exposes the kth largest at the root.
G4. "In a grid of land and water, repaint every land cell connected to a given starting cell."
"Everything connected to a cell" is a graph reachability question wearing a grid costume — flood fill from the start with visited marking.

Section H — Full Coding Questions 🟢 🟡 🟠

The full ladder: attempt on paper → hint → approach → solution.

H1. Majority Element

🟢 Easy
Problem

Find the element appearing more than half the time. It is guaranteed to exist; n can be 100,000.

Input / Output

[3,2,3] → 3 · [2,2,1,1,1,2,2] → 2

Show Hint

You do not need to sort — you need to know who leads the count.

Show Approach

Count every value with a frequency map, then return the key with the biggest count. O(n) time, O(n) space.

Show Solution
Python
def majority_element(nums):
    counts = {}
    for x in nums:
        counts[x] = counts.get(x, 0) + 1

    best_val = None
    best_count = 0
    for value, count in counts.items():
        if count > best_count:
            best_val = value
            best_count = count
    return best_val
Pattern Recognition
Pattern Insight

"Who appears most?" is a frequency-map question. The "more than half" guarantee would even allow the O(1)-space candidate-counting trick — but the map is the template you should reach for first.

H2. First and Last Position in a Sorted Array

🟡 Easy+
Problem

Sorted array with duplicates: return [first, last] indices of the target, or [-1, -1]. Must be O(log n).

Input / Output

[5,7,7,8,8,10], target 8 → [3, 4] · target 6 → [-1, -1]

Show Hint

Two binary searches. One hunts left after a hit, the other hunts right.

Show Approach

Run the first-occurrence and last-occurrence templates from Part 28 — each records a candidate on a hit and keeps narrowing in its own direction.

Show Solution
Python
def search_range(nums, target):
    def find(first):
        lo, hi = 0, len(nums) - 1
        ans = -1
        while lo <= hi:
            mid = (lo + hi) // 2
            if nums[mid] == target:
                ans = mid
                if first:
                    hi = mid - 1    # keep hunting left
                else:
                    lo = mid + 1    # keep hunting right
            elif nums[mid] < target:
                lo = mid + 1
            else:
                hi = mid - 1
        return ans

    return [find(True), find(False)]
Pattern Recognition
Pattern Insight

"First/last of X in sorted data at O(log n)" — the two-variant binary search family. One flag turns the same loop into both answers.

H3. Top K Frequent Elements

🟠 Beginner Intermediate
Problem

Return the k most frequent elements (any order). k is smaller than the number of distinct values.

Input / Output

[1,1,1,2,2,3], k = 2 → [1, 2]

Show Hint

Two patterns glued together: count first, then keep a running "k best".

Show Approach

Build the frequency map, push (−count, value) pairs into a heap (negation = max-heap on count), then pop k times.

Show Solution
Python
import heapq

def top_k_frequent(nums, k):
    counts = {}
    for x in nums:
        counts[x] = counts.get(x, 0) + 1

    heap = []                      # (-count, value): min-heap = max by count
    for value, count in counts.items():
        heapq.heappush(heap, (-count, value))

    result = []
    for _ in range(k):
        result.append(heapq.heappop(heap)[1])
    return result
Pattern Recognition
Pattern Insight

Frequency map + heap is the standard composition behind every "top k / most frequent" problem. For strictly O(n log k) you would keep the heap capped at size k, as in 29.36.

Score Yourself

SectionItemsSuggested points eachMax
A — Concepts5 quizzes15
B — Predict the Output5 quizzes15
C — Find the Bug4 reveals2 (1 if you needed the fix line)8
D — Complexity4 quizzes14
E — Trace3 dry runs2 (all steps written)6
F — Fill the Code4 reveals28
G — Name the Pattern4 quizzes14
H — Full Problems3 cards4 (worked, correct, traced)12
Tip — how to grade yourself honestly
  • Score only what you produced. "I knew it" after the reveal counts as a miss.
  • Half credit for the right idea with an off-by-one or a wrong pointer move.
  • No credit for an item you saw earlier today — memorized answers are noise, not signal.
  • Below 35/52: redo Sections C, E and F tomorrow before touching new chapters.
  • Above 45/52: move to Part 31 and try the timed pattern-guessing board.

What You Learned

  • Eight distinct exercise types, and what each one trains: recall, reading code, debugging, analysis, tracing, completion, recognition, and full solving.
  • That most bugs cluster into known families: = vs ==, bad initialization, convention mismatches, and mutating while iterating.
  • That dry-running on paper catches bugs faster than staring at code.
  • An honest self-scoring habit that measures production, not recognition.

Pattern Recognition

Section G is a preview of Part 31, and Section H's cards reuse Part 28 templates verbatim. The takeaway: when your practice score dips, the fix is rarely "more problems" — it is "more attempts before reveals". Difficulty here was capped at beginner-intermediate on purpose: fluency at this level is what the harder patterns will assume.

Complexity Summary

Skill checkedWhat a correct answer demonstrates
Predict the outputYou execute code in your head, honoring order of operations and mutation
Find the bugYou test edge cases mentally (empty input, negatives, single element)
Complexity checkYou count work per element/pointer, not per line
Trace the algorithmYou maintain state (pointers, stacks, running sums) without drift
Name the patternThe clue-word reflex from Part 28 is becoming automatic
Full problemsAttempt → hint ladder → correct, traced, ≤ 20-line solution

Common Mistakes

  • Opening reveals early "just to check the direction" — that resets the exercise's value to zero.
  • Tracing in your head without writing state down; the written trace is the exercise.
  • Grading generously on Section H because the code "looks right" — trace it or it does not count.
  • Repeating only the sections you are already good at.

Mini Quiz

Q1. binary_search([5], 5) with while lo < hi returns −1 because…
lo = 0 and hi = 0 already, and "lo < hi" is false — the classic convention mismatch the <= convention fixes.
Q2. What does list("ab") * 2 evaluate to?
list("ab") is ['a', 'b'], and multiplying a list repeats its elements — a list of 4 strings, not a string.
Q3. "Shortest subarray with sum at least target, all values positive" points to…
Positivity makes the window sum monotonic, so grow-then-shrink finds the shortest valid window in one pass.
Q4. The O(n log k) signature belongs to…
n elements each doing an O(log k) heap operation inside a heap capped at size k.
Q5. The honest scoring rule this chapter recommends is…
Familiarity is not ability. Only produced answers measure the skill the worksheet trains.

Practice Problems

Practice — Write One, Predict One

🟢 L1

Write your own predict-the-output item (5 lines or fewer) about lists or dicts, solve it on paper, then run it to check.

Show Hint

The best traps involve mutation, aliasing, or a method's return value.

Show Approach

Example: a = [1, 2]; b = a; b.append(3); print(a) — do both names see the change?

Show Solution

Yes — [1, 2, 3]. b = a copies the reference, not the list. That is the Copy vs Modify trap from Part 3.

Pattern clue: writing the trap teaches you where your own instincts are weakest.

Practice — Fix the Truncated Template

🟡 L2

This move-zeroes attempt fills the kept values but forgets the tail. Complete it.

Python
def move_zeroes(nums):
    write = 0
    for read in range(len(nums)):
        if nums[read] != 0:
            nums[write] = nums[read]
            write += 1
    # TODO: finish the job
    return nums
Show Hint

Slots from write to the end are unclaimed — what belongs there?

Show Approach

Overwrite every slot from write to len(nums) − 1 with 0.

Show Solution
Python
    for i in range(write, len(nums)):
        nums[i] = 0

move_zeroes([0,1,0,3]) → [1,3,0,0] ✅

Pattern clue: the read/write template leaves the dropped region untouched — finishing it is always a second small loop.

Practice — Complete the Hunt

🟡 L2

Fill the gap so this function finds the first occurrence of target in a sorted array with duplicates.

Python
def first_occurrence(nums, target):
    lo, hi = 0, len(nums) - 1
    ans = -1
    while lo <= hi:
        mid = (lo + hi) // 2
        # TODO: handle a hit so the search keeps moving LEFT
        ...
        elif nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return ans
Show Hint

On a hit you cannot stop — but you know every earlier index is a candidate worth checking.

Show Approach

Record ans = mid and set hi = mid − 1.

Show Solution
Python
        if nums[mid] == target:
            ans = mid          # candidate — keep hunting left
            hi = mid - 1

first_occurrence([1,2,4,4,4,7], 4) → 2 ✅

Pattern clue: first/last occurrence = base search + "do not stop on a hit".

Challenge Problem

Challenge — One Flip, Longest Run

🟠 L3 · Recognize
Problem

Binary array; you may flip exactly one 0 into a 1 (or none if the array is all 1s). Longest run of 1s achievable, in one pass?

Input / Output

[1,0,1,1,0] → 4 (flip index 1: 1,1,1,1)

Show Hint

This is 29.12 with k = 1. The window may hold at most one zero.

Show Approach

Count zeroes in the window; while the count exceeds 1, shrink from the left. Track the longest valid window.

Show Solution
Python
def longest_ones_one_flip(nums):
    left = 0
    zeros = 0
    best = 0
    for right, x in enumerate(nums):
        if x == 0:
            zeros += 1
        while zeros > 1:          # only one flip allowed
            if nums[left] == 0:
                zeros -= 1
            left += 1
        best = max(best, right - left + 1)
    return best
Pattern Recognition
Pattern Insight

"At most k exceptions inside a contiguous stretch" is a window-validity counter — one parameter change turns the general k-flip template into this exact variant.

Revision Checklist

PART 31

Pattern Recognition Training

The guessing board: sixteen problems, no pattern named — until you commit.

This is the exam the whole book has been building toward. Each challenge gives you a realistic problem statement — with constraints, just like a real interview — and a set of candidate patterns. Your job is to pick the technique before touching any code. Only after you commit do you open the clue analysis, which shows the exact words in the statement that pointed to the answer.

Cover the reveals

Read the statement, give yourself ten seconds, and answer out loud with a reason — "hashing, because it is unsorted and I need fast lookups". A pattern picked without a reason is a coin flip. Aim to finish the whole board with at least 12 of 16; anything less means re-drill the missed pattern's chapter before your next session.

The Challenge Board

Challenge 1 — Two Prices That Hit the Target

🟢 Easy

You are given an unsorted list of n stock prices (n up to 100,000) and a target total. Return the indices of the two prices that add up to the target. A brute-force scan over all pairs times out.

Example: [7, 11, 2, 15], target 9 → indices [0, 2]

Which technique?
Unsorted + O(n) target + indices needed → one pass with a value-to-index map.
Reveal the clue analysis
  • Clue 1 — "unsorted": opposite-end two pointers need sorted order, so that lane closes.
  • Clue 2 — "times out" on pairs: the O(n²) pair scan must be replaced by one pass.
  • Clue 3 — "return the indices": sorting would destroy them unless you track them — a map avoids all that.

Answer — Hashing: walk once, and for each price ask the map "is target − price already here?".

Challenge 2 — The Longest Clean Stretch

🟢 Easy

A string of up to 100,000 lowercase letters. Find the length of the longest substring in which every character is different from every other.

Example: "abcabcbb" → 3 (the stretch "abc")

Which technique?
Longest + contiguous + a validity rule that flips on one bad character → grow, shrink, remember.
Reveal the clue analysis
  • Clue 1 — "substring": contiguous — windows and prefix sums are the contiguous specialists.
  • Clue 2 — "longest": the window grows while valid and shrinks the moment it is not.
  • Clue 3 — "every character different": a set of the window's contents decides validity in O(1).

Answer — Sliding Window: the set is the tool, but the window is the frame — validity is checked as the right edge advances.

Challenge 3 — Undo, Instantly

🟢 Easy

A text editor records changes. "Apply" adds a change; "undo" must remove the most recent change in O(1). Design the storage.

Example: apply a, apply b, undo → the state goes back to just a

Which technique?
"Most recent first" is the stack's one-line definition.
Reveal the clue analysis
  • Clue 1 — "most recent": the newest item must be the next one out — LIFO.
  • Clue 2 — "in O(1)": appending and popping from the end of a list is O(1); no scanning needed.
  • Wrong-turn check: a queue would undo the oldest change first — the opposite of undo.

Answer — Stack: push on apply, pop on undo. The browser back button works the same way.

Challenge 4 — Count the Exact-Sum Stretches

🟡 Easy+

An array of up to 200,000 integers — values may be negative. Count how many contiguous subarrays sum to exactly k.

Example: [1, 1, 1], k = 2 → 2

Which technique?
Negative values kill the window; running sums + a frequency map count every exact-sum stretch in one pass.
Reveal the clue analysis
  • Clue 1 — "subarrays that sum to…": the signature phrase for running sums.
  • Clue 2 — "values may be negative": growing a window can now lower the sum, so shrink decisions become unsafe — the window is disqualified.
  • Clue 3 — "count": you need how many earlier prefixes complete a k-sum, i.e. a frequency map of running sums.

Answer — Prefix Sum + hash map: count occurrences of running − k as you sweep.

Challenge 5 — Twenty Questions for a Million IDs

🟡 Easy+

A sorted array holds one million user IDs. Membership queries must each finish in at most about 20 comparisons. Which technique delivers that guarantee?

Example: is 4,733,901 in the sorted ID list? Answer with a yes/no in 20 probes.

Which technique?
2^20 is just over a million — the constraint literally describes log₂(n) probes on sorted data.
Reveal the clue analysis
  • Clue 1 — "sorted": halves are knowable — you can discard half the array from one comparison.
  • Clue 2 — "about 20 comparisons for a million": 2^20 = 1,048,576. That number is log₂(n).
  • Wrong-turn check: hashing answers in O(1) average — even better — but the "20 comparisons" bound is the fingerprint of halving, not of hashing.

Answer — Binary Search: halve the search space until one candidate remains.

Challenge 6 — The Meeting Room Crunch

🟡 Easy+

You get n meetings as [start, end] times (n up to 100,000). Find the minimum number of rooms so that no two meetings ever share one.

Example: [[0,30],[5,10],[15,20]] → 2

Which technique?
Start/end pairs plus "how many overlap at once" is the sort-and-sweep signature.
Reveal the clue analysis
  • Clue 1 — "[start, end] pairs": the intervals template's calling card.
  • Clue 2 — "minimum number so nothing overlaps": a peak-overlap count — exactly what a sorted endpoint sweep computes.
  • Wrong-turn check: a size-heap works too (push end times, evict finished ones) — but it is the same interval data sorted by time; the data type is the tell.

Answer — Intervals: sort starts and ends, walk both, track the maximum simultaneous meetings.

Challenge 7 — The Kth Largest, Live

🟡 Easy+

Test scores arrive one at a time (up to 100,000 of them). After each arrival, report the kth largest so far, where k is small (say 10). Full sorting after every arrival is far too slow.

Example: k = 3; stream 4, 5, 8, 2, 3 → reports 4 (after 3 arrives), then 5, 5, 8 as the stream grows

Which technique?
A running "k best" that answers after every update is the size-k heap's exact job.
Reveal the clue analysis
  • Clue 1 — "after each arrival": a persistent structure that updates incrementally, not a per-query recomputation.
  • Clue 2 — "kth largest, k small": only the top k can ever matter — keep exactly those, nothing else.
  • Clue 3 — "sorting is too slow": you need O(log k) per arrival, which is one heap operation.

Answer — Heap: push the newcomer; if the heap outgrows k, evict its smallest. The root is the kth largest.

Challenge 8 — Every Ordering of a Word

🟡 Easy+

Print all orderings (permutations) of the letters of a short word of length n (n ≤ 8).

Example: "abc" → abc, acb, bac, bca, cab, cba

Which technique?
"All orderings" is a decision tree: choose a letter, recurse on the rest, undo the choice.
Reveal the clue analysis
  • Clue 1 — "all orderings": the output is a set of configurations, not a single value — backtracking territory.
  • Clue 2 — "n ≤ 8": at most 8! = 40,320 results, so exhaustive enumeration is affordable.
  • Clue 3 — the shape: at each step you pick one unused letter — a choice, an exploration, and (inevitably) an undo.

Answer — Recursion / Backtracking: the choose → explore → undo loop over unused letters.

Challenge 9 — Out of the Maze

🟠 Intermediate

A maze is a grid of open and walled cells (up to 1,000 × 1,000). Find the minimum number of steps from the entrance to the exit, moving up/down/left/right through open cells.

Example: the shortest path through the open cells is 7 steps → answer 7

Which technique?
Shortest path + unweighted moves + a grid: BFS expands in rings of increasing distance, so the first arrival is the shortest.
Reveal the clue analysis
  • Clue 1 — "minimum number of steps": shortest-path language.
  • Clue 2 — every move costs the same: unweighted — no priority machinery needed.
  • Clue 3 — a grid: each cell's neighbours are the 4 adjacent cells; the grid is the graph.

Answer — BFS: a queue explores distance-layer by distance-layer; DFS would find a path, not the shortest one.

Challenge 10 — The Loop in the List

🟠 Intermediate

A linked list may loop back onto itself somewhere. Detect whether a cycle exists using O(1) extra space — storing seen nodes is not allowed.

Example: 1 → 2 → 3 → points back to 2 → cycle: yes

Which technique?
Two pointers at different speeds, O(1) space: inside a loop, the fast one must lap the slow one.
Reveal the clue analysis
  • Clue 1 — "O(1) extra space": a set of seen nodes is O(n) — disqualified, however natural it feels.
  • Clue 2 — a sequence you can only walk forward: the tools are pointers, and two speeds create a race.
  • The math: inside the loop the gap between fast (2 steps) and slow (1 step) shrinks by exactly 1 each round — it can never skip past 0.

Answer — Two Pointers (fast/slow): if they ever meet, there is a cycle.

Challenge 11 — The Unbroken Log Rule

🟠 Intermediate

A sorted array was rotated at an unknown pivot — e.g. [4,5,7,0,1,2] came from [0,1,2,4,5,7]. Find a target value in O(log n).

Example: [4,5,7,0,1,2], target 0 → index 3

Which technique?
O(log n) + still half-sorted data: one side of any midpoint is always sorted, so the halving survives.
Reveal the clue analysis
  • Clue 1 — "O(log n)": that complexity basically only comes from halving.
  • Clue 2 — "sorted, then rotated": not fully sorted, but any midpoint splits it into one sorted half and one rotated half.
  • Clue 3 — the move: check which half is sorted; ask whether the target lies inside it; discard the other half.

Answer — Binary Search (modified): the classic loop with one extra "which half is sorted?" test per step.

Challenge 12 — Collect Every Key

🟡 Easy+

A building's rooms are connected by doorways. You must visit every room and collect every key; the visiting order does not matter, and recursion is allowed. Which technique fits this "explore everything reachable" job?

Example: rooms 0-1-2 chained, plus room 3 attached to 0 → all four get visited from room 0

Which technique?
Explore-everything with no order requirement: DFS (BFS works equally well — but only DFS is offered here, and its recursion matches the hint).
Reveal the clue analysis
  • Clue 1 — "visit every room": reachability, not shortest distance — order is irrelevant.
  • Clue 2 — "recursion is allowed": a nudge toward the recursive walk with a visited set.
  • Wrong-turn check: BFS also visits everything; it is the sibling answer. The problem type is graph traversal either way.

Answer — DFS: recurse into each unvisited neighbour; the visited set is what ends the recursion.

Challenge 13 — Serve the Highest Priority First

🟡 Easy+

Customers arrive with priorities, and priorities of waiting customers can change. The system must repeatedly serve the current highest-priority customer, quickly, forever.

Example: queue holds priorities 3 and 7 → serve 7; a 9 arrives → serve 9 next

Which technique?
"Repeatedly extract the current best from a changing set" is the heap's definition of employment.
Reveal the clue analysis
  • Clue 1 — "repeatedly serve the highest priority": repeated max-extraction.
  • Clue 2 — "the set changes": a sorted-once list goes stale; you need insert-and-extract that stay fast.
  • Wrong-turn check: a plain queue serves by arrival order, not priority — the story explicitly overrides arrival order.

Answer — Heap (priority queue): push on arrival, pop-max to serve — both O(log n).

Challenge 14 — Total of a Tree

🟢 Easy

Given the root of a binary tree with up to 10,000 nodes, return the sum of all node values.

Example: tree 1 / (2, 3) → sum 6

Which technique?
A tree's own recursive definition invites a recursive solution: value + left sum + right sum.
Reveal the clue analysis
  • Clue 1 — "binary tree": every node has the same shape of subproblem hanging off it.
  • Clue 2 — the whole tree = root + both subtrees: trust that the recursion already sums the subtrees correctly.
  • Clue 3 — "up to 10,000 nodes": depth stays modest, so recursion is safe here.

Answer — Tree Recursion: base case None → 0; otherwise node.val + sum(left) + sum(right).

Challenge 15 — How Long Has It Been This Cheap?

🟡 Easy+

Given a stream of daily stock prices, for each day report how many consecutive previous days (including today) had a price at most today's. n up to 100,000 — a scan back for every day is too slow.

Example: [100, 80, 60, 70, 60, 75, 85] → [1, 1, 1, 2, 1, 4, 6]

Which technique?
"Previous greater element" for every item: a stack of days keeps exactly the candidates that can still matter.
Reveal the clue analysis
  • Clue 1 — "at most today's, looking back": a previous-greater-element question in disguise.
  • Clue 2 — "scan back for every day is too slow": the O(n²) double loop must die; each day should be touched a constant number of times.
  • Clue 3 — who survives: any day colder than a later, warmer day can never answer a future question — so it can be dropped, which is precisely what a stack does.

Answer — Stack: pop every day cheaper than today; the span is today's index minus the new top (this is the Stock Span, Part 11's challenge).

Challenge 16 — Squeeze In One More Meeting

🟠 Intermediate

One conference room, n candidate meetings with [start, end] times. Schedule the maximum number of non-overlapping meetings.

Example: [[1,3],[2,4],[3,5]] → 2 (take [1,3] and [3,5])

Which technique?
A single shared resource and a "maximum count" goal: take the earliest-finishing option, then recurse on what remains.
Reveal the clue analysis
  • Clue 1 — "maximum number": count-based optimization over choices.
  • Clue 2 — "one room": a single shared resource — the classic greedy setup.
  • Clue 3 — the exchange test: swapping any optimal schedule's first meeting for the earliest-finishing one never loses a slot — the local choice is safe.

Answer — Greedy: sort by end time and take every meeting that starts after the last one taken (Part 19).

Clue Phrase → Pattern Cheat Sheet

The board above keeps reusing the same tell-tale phrases. Collect them here, in the order you will meet them in the wild:

If the statement says…Reach for…Why the words point there
"have I seen this?" / "two items that…" (unsorted)HashingFast existence checks and complement lookups
"sorted array" + "pair" + "O(1) space"Two PointersSorted ends let you discard one candidate per step
"longest/shortest contiguous stretch"Sliding WindowGrows and shrinks; each element enters once
"subarrays that sum to…" (esp. with negatives)Prefix SumRunning sums make every stretch one subtraction or lookup
"sorted" + "O(log n)" / a bounded comparison countBinary SearchHalving is the only O(log n) game in town
"most recent" / "undo" / "nested" / "matching"StackLast-in-first-out is the required discipline
"in arrival order" / "level by level"QueueFirst-in-first-out preserves processing order
"explore everything, order irrelevant"DFS / BFSReachability with a visited set
"shortest path / fewest steps" (unweighted)BFSLayer-by-layer expansion reaches the goal at minimal distance
"kth largest" / "top k" / "most frequent" / "highest priority"HeapA size-k (or extract-max) heap tracks the current best cheaply
"make the local best choice" + a single resourceGreedySort into choice order; verify with the exchange test
"[start, end] pairs" / "overlap" / "how many at once"IntervalsSort the endpoints and sweep
"left/right children" / "subtree"Tree RecursionThe recursive structure mirrors the recursive solution
"all subsets / orderings / configurations"BacktrackingChoose, explore, undo — exhaustively but pruned
Pattern Insight — read the constraint, not the story

Stories are camouflage; constraints are fingerprints. "n up to 100,000 with a time limit" rules out O(n²). "O(1) extra space" rules out hash sets. "at most 20 comparisons" is log₂(n). Train yourself to translate every constraint into a complexity budget before choosing a pattern — the pattern that fits the budget usually fits the problem.

Tip — run the board twice

After you finish the book, come back and redo this board. The first pass measures what you learned; the second measures what stuck. A pattern you can name in under five seconds, with its reason, is a pattern you own.

What You Learned

  • Sixteen realistic statements mapped to their patterns — by clue words, not by chapter order.
  • How constraints (n limits, space limits, comparison counts) narrow the pattern choice before any code exists.
  • The wrong-turn check: for every challenge, why the near-miss pattern was rejected.
  • A cheat sheet of clue phrases you will meet in every future problem set.

Pattern Recognition

This chapter is pattern recognition. The skill it trains has three beats: read the constraint, name the budget, name the pattern. When you miss an item, the repair is never "solve more problems like it" — it is "reread that pattern's chapter until its clue phrases feel obvious". The cheat-sheet table above is the compressed version of the whole book.

Complexity Summary

PatternTypical timeSignature constraint it satisfies
HashingO(n)One pass; extra O(n) memory allowed
Two PointersO(n)Sorted input, O(1) extra space
Sliding WindowO(n)Contiguous + positive/monotonic sums
Prefix SumO(n)Range or subarray sums, negatives allowed
Binary SearchO(log n)Sorted or monotonic answer space
StackO(n)Most-recent-first; next-greater questions
Queue / BFSO(V + E)Unweighted shortest path; level order
DFS / Tree RecursionO(n)Explore all; subtree-shaped subproblems
HeapO(n log k)Running top-k or repeated max extraction
Greedy / IntervalsO(n log n)One resource; start/end pair data
BacktrackingO(2^n)–O(n!)Exhaustive but tiny n (usually n ≤ 10–20)

Common Mistakes

  • Answering by story vibes ("it has rooms, so heap") instead of by constraints.
  • Choosing hashing when the statement caps memory, or windows when values go negative.
  • Picking a pattern without being able to say why in one sentence.
  • Skipping the wrong-turn analysis — knowing why the runner-up fails is half the skill.
  • Treating the cheat sheet as a substitute for the tracing habit; patterns still need dry runs.

Mini Quiz

Q1. "Shortest path through an unweighted maze" — the deciding clue pair is…
Unweighted shortest path is BFS's exact specialty; DFS would only guarantee some path.
Q2. Why does "O(1) extra space" disqualify hashing for cycle detection (Challenge 10)?
Constraints are filters: a memory cap removes any solution whose footprint scales with n, leaving the fast/slow pointers.
Q3. "At most about 20 comparisons for a million sorted items" is a fingerprint of…
Halving a million 20 times leaves one element — the constraint literally states log₂(n).
Q4. Negative values in a "count subarrays summing to k" problem push you from windows to…
Negatives break the window's "grow = sum grows" assumption; running sums and a map handle them cleanly.

Practice Problems

Practice — The Blind Replay

🟢 L1

Shuffle this board (cover the quiz options entirely), then re-answer all 16 challenges with a one-sentence reason each. Score out of 16.

Show Hint

Cover everything below each statement's first two lines — the example footnote is fair game.

Show Approach

Say the constraint budget first ("n = 100,000, one pass needed"), then the pattern.

Show Solution

Twelve or more correct means the reflex is forming; below that, revisit the missed pattern's chapter, not this board.

Pattern clue: repetition under self-imposed pressure converts recognition into reflex.

Practice — Borrowed Statements

🟡 L2

Open Part 29, take any five cards, cover their group heading and pattern clue, and name each card's pattern plus one clue phrase that justified it.

Show Hint

Pick at least two cards from groups you consider your weakest.

Show Approach

For each card, the justification must quote a phrase from its own statement — not the group name.

Show Solution

Any card you cannot justify with a quoted clue goes on the redo pile with Part 29's other misses.

Pattern clue: justification by quoted evidence is what separates knowing from guessing.

Practice — Two-Minute Drills

🟡 L2

Have a friend (or a notes file) read you Challenge statements at random; answer with pattern + reason within two minutes, no paper.

Show Hint

The cheat-sheet table is your allowed study material between rounds, not during them.

Show Approach

Restrict the deck to your last five missed patterns to sharpen exactly where you are weak.

Show Solution

Three correct-in-two-minutes answers per pattern in a row retires that pattern from the drill deck.

Pattern clue: timed recall with a shrinking deck is spaced repetition in disguise.

Challenge Problem

Challenge — Write the Exam

🟠 L3 · Recognize
Problem

Write three new challenge items in the style of this board: one realistic statement with constraints, three plausible pattern options, exactly one correct, and a clue analysis that names the phrase that gives it away. Then exchange with a friend (or leave them for your own future self for a week).

Input / Output

No code required — the deliverable is three statements, three option sets, three clue analyses.

Show Hint

Start from the cheat-sheet table: pick a clue phrase, then invent a story that hides it.

Show Approach

Good distractors are sibling patterns from the same family — window vs prefix sum, DFS vs BFS, greedy vs intervals. A distractor must be defensible for ten seconds before the constraint kills it.

Show Solution

Quality check: hand your items to someone who has read the book. If they can name pattern AND reason inside ten seconds, your clues were honest; if they guess, your distractors were too weak.

Pattern Recognition
Pattern Insight

Writing distractors is the deepest drill there is: to make a plausible wrong option you must know exactly which constraint eliminates it — which is the entire skill this board teaches.

Revision Checklist

PART 32

Complexity Master Table

Every operation cost you have met, on one page — plus the honest notes about what Python really does.

Part 1 taught you to count; Part 10, 17 and 20 showed you costs inside specific structures. This part is the consolidated cheat sheet: for each data structure, what every operation costs, which structure to reach for, and how big n can get before your idea stops working. When you are mid-problem and wondering “is insert(0, x) okay here?”, the answer should be a thirty-second lookup, not an experiment.

Concept — how to read these tables

Average is what you usually pay; worst is the rare pathological case (hash collisions, a degenerate tree). Amortized means expensive steps happen so rarely that, spread over many operations, the average per operation is small — list.append is O(1) amortized even though Python occasionally rebuilds the array. Unless a table says otherwise, entries are average / typical costs.

Array / List

OperationCodeCostWhy
Access by indexnums[i]O(1)address arithmetic — jump straight to slot i
Search (unsorted)x in numsO(n)must check items one by one
Append at endnums.append(x)O(1) amortizedoccasionally Python copies into a bigger block
Pop from endnums.pop()O(1)remove the last slot, nothing shifts
Insert at frontnums.insert(0, x)O(n)every element shifts right
Pop from frontnums.pop(0)O(n)every element shifts left
Insert / delete in middlenums.insert(i, x) / del nums[i]O(n)everything after i shifts
Slice of k itemsnums[a:b]O(k)copies k elements
Sortnums.sort()O(n log n)Timsort — and it is adaptive on partly-sorted data
Common Mistake — the front-of-list trap

nums.pop(0) and nums.insert(0, x) look like O(1) because they name one index — but a Python list is a contiguous array, so all one million other elements shuffle sideways. Need fast operations at the front? That is a job for a deque, not a list.

access
append
pop()
pop(0)
insert(0,x)
x in list
Steps on a 1,000,000-element list: front operations and membership tests touch up to a million slots; end operations touch one.

Dictionary

OperationCodeCostNotes
Lookup by keyd[k], k in dO(1) avgworst O(n) on pathological hash collisions
Insert / updated[k] = vO(1) avg
Deletedel d[k], d.pop(k)O(1) avgcheck k in d or use .get first
Iterate keys / valuesfor k in d:O(n)visiting everything is never free

The dictionary is the pattern library’s workhorse (P1, P2): it turns a repeated search into a lookup. That trade — O(n) memory for O(1) answers — is the single most common optimization in this book.

Set

OperationCodeCostNotes
Membershipx in sO(1) avgthe whole reason seen-sets beat lists
Adds.add(x)O(1) avgno-op if already present
Removes.remove(x) / s.discard(x)O(1) avgremove raises if missing, discard does not
Union / intersectiona | b, a & bO(len(a) + len(b))

Stack (a list used as one)

OperationCodeCost
Pushstack.append(x)O(1) amortized
Popstack.pop()O(1)
Peek topstack[-1]O(1)
Size / emptylen(stack), not stackO(1)

Every cost is O(1) because a stack only ever touches the end of the list — the one place arrays are naturally cheap. This is why Part 11’s parentheses checker runs in a clean O(n).

Queue / Deque

OperationCodeCostNotes
Enqueue (back)q.append(x)O(1)on collections.deque
Dequeue (front)q.popleft()O(1)the reason BFS queues use deque
Push frontq.appendleft(x)O(1)double-ended bonus
Peek either endq[0], q[-1]O(1)
Index in middleq[i]O(n)⚠️ deque is not a random-access array
Dequeue via listlst.pop(0)O(n)⚠️ shifts everything — never in a loop

Heap (heapq — a min-heap)

OperationCodeCostNotes
Peek bestheap[0]O(1)the minimum — heapq is a min-heap
Pushheapq.heappush(heap, x)O(log n)one sift-up
Pop bestheapq.heappop(heap)O(log n)one sift-down
Build from a listheapq.heapify(lst)O(n)cheaper than n pushes
Search arbitrary itemx in heapO(n)heaps answer “best?”, not “where is x?”

Linked List

OperationCostNotes
Traverse / searchO(n)no indexes — follow the arrows
Insert at headO(1)two pointer writes
Insert at tail (with tail pointer)O(1)keep a reference to the last node
Insert at tail (no tail pointer)O(n)walk to the end first
Delete a node you already hold (with prev)O(1)rewire neighbors; no shifting, ever
Delete by valueO(n)finding it is the cost, not the deletion

Python has no built-in linked list — you built the node class yourself in Part 13. The table above is the honest comparison the part opened with: lists pay O(n) to splice in the middle; a linked list pays O(1) once it is there, but pays O(n) to get there.

Binary Search Tree

OperationAverage (balanced-ish)Worst (degenerate)When the worst happens
SearchO(log n)O(n)tree degenerates into a linked list
InsertO(log n)O(n)inserting already-sorted data
DeleteO(log n)O(n)same degradation
In-order traversalO(n)O(n)always — and it visits values in sorted order
Important — Python honesty about BSTs

Python’s standard library has no built-in balanced BST. Insert sorted data into a plain BST and every node hangs off the right — search falls back to O(n). Self-balancing trees (AVL, red-black) keep the O(log n) promise, but they live outside the standard library. In practice, Python code uses sorted lists + bisect for ordered lookups: bisect finds the position in O(log n), but insort still pays O(n) to shift elements — an honest middle ground, not a magic tree.

Best structure for the job

The same table, flipped: start from the need, not the structure.

You need…Reach forBecause
Fast membership: “have I seen x?”setO(1) average lookups
Count / associate: “how many, or where?”dictO(1) average, keeps the value too
Process in arrival order (FIFO)collections.dequeO(1) at both ends
Last-in-first-out (LIFO)list (append / pop)O(1) at the end, nothing else needed
Repeatedly take the current bestheapqO(log n) push/pop, O(1) peek
Sum of arbitrary ranges, many queriesprefix-sum listbuild once O(n), then O(1) per query
Read by index, cheap at the endlistO(1) access, O(1) append/pop
Frequent inserts/removals at both endscollections.dequeO(1) both directions
Ordered data + search by positionsorted list + bisectO(log n) search; inserts cost O(n) to shift
Keep only the k best seen so farheapq of size kO(n log k) total, tiny memory

When n grows, what still works

Recall the budget from Part 1: a machine does roughly 10 million simple steps per second. Now hold n steady and watch which complexities survive.

nO(log n)O(n)O(n log n)O(n²)O(2ⁿ)
10✅ 4 steps✅ 10✅ 34✅ 100✅ 1,024
1,000✅ 10✅ 1,000✅ 10,000⚠️ 1,000,000 (about 0.1 s)❌ more steps than atoms you can count
100,000✅ 17✅ 100,000✅ about 1.7 million❌ 10 billion — hours❌ unthinkable
10,000,000✅ 24✅ 10 million — about 1 s⚠️ about 230 million — tens of seconds❌ 100 trillion — months❌ —
Tip — read the constraint as a verdict

The constraints line of a statement is a complexity verdict in disguise. n ≤ 10⁵ (100,000) permits O(n log n) and forbids O(n²); n ≤ 20 quietly invites backtracking over 2ⁿ subsets. Translate constraints before designing — this is rule 2 of Part 33.

O(1)
O(log n)
O(n)
O(n log n)
O(n²)
Relative work for the same n (compressed for readability — the real gap between O(n²) and the rest is far larger than the bars suggest).

Where Python surprises you

  • list.append is amortized O(1), not literal O(1) — occasionally Python over-allocates and copies. The average per append stays constant, which is what matters.
  • pop(0) / insert(0, x) are O(n) — the array-shift trap shown above.
  • String concatenation in a loop (s += chunk) can be O(n²) because strings are immutable — build a list and "".join(parts) for O(n) (Part 4).
  • Dict/set costs are average-case — adversarial inputs with hash collisions degrade lookups toward O(n). Fine to ignore in practice, honest to know.
  • heapq is a min-heap with no max flag — simulate a max-heap by negating values in and out (Part 17).
  • No built-in balanced BST — ordered structures are sorted lists plus bisect, with honest O(n) inserts.
  • Recursion depth is capped near 1000 frames — a deep DFS on a big grid must become iterative (Part 14).
Q1. Your code calls nums.insert(0, x) once per element inside a loop over 100,000 elements. Overall cost?
Each front insert shifts all n elements sideways: O(n) per call, O(n²) total. A deque does the same job in O(1) per call.
Q2. BFS uses a queue. What happens to complexity if you implement the queue as a Python list with pop(0)?
The algorithm’s logic is unchanged, but every pop(0) pays an O(n) shift — a structure choice, not an algorithm choice, wrecks the runtime.
Q3. You insert 1, 2, 3, …, n (in order) into a plain BST. Searching it then costs…
Sorted insertions make every node a right child: the “tree” is a chain, and search walks it end to end.

What You Learned

  • Operation costs for list, dict, set, stack, deque, heap, linked list, and BST — average vs worst vs amortized, and what each really means.
  • Python implementation details change the bill: pop(0) shifts O(n), append is amortized, dicts are average-case, heapq is a min-heap, there is no built-in balanced BST.
  • “Best structure for the job”: start from the operation you need most often, not from the structure you know best.
  • The n-grows table turns any constraint line into a verdict on your idea before you write it.

Pattern Recognition

Complexity is a recognition filter, not an afterthought. “Fast lookup” in the statement → dict/set; “both ends” → deque; “in order” → queue; “current best” → heap; “sorted + search” → binary search. When the budget from the constraints table kills your first idea, the killed complexity tells you which structure replaces it.

Complexity Summary

StructureIts superpowerIts weakness
listO(1) index, O(1) end opsO(n) front/middle ops, O(n) search
dict / setO(1) avg lookup, insert, deleteworst-case O(n) on collisions; no ordering
dequeO(1) both endsO(n) middle access
heapO(1) peek, O(log n) push/popO(n) arbitrary search; min-only in Python
linked listO(1) splice once positionedO(n) to reach any position
BSTO(log n) average search/insert/deleteO(n) worst case without balancing
sorted list + bisectO(log n) search positionO(n) insert (shifting)

Common Mistakes

  • Reading insert(0, x) as O(1) because it names one index — the shift is the cost.
  • Using a list where a set belongs: x in some_list inside a loop is the classic silent O(n²).
  • Trusting BST O(log n) without asking whether the input could be sorted (the degenerate case).
  • Indexing into a deque in the middle of an algorithm — deques are ends-only structures.
  • Assuming “average case” dict claims apply to worst-case adversarial inputs.

Mini Quiz

Q1. Which is not O(1) on a Python list of size n?
Inserting at index 2 shifts elements 2..n-1 right — O(n). Only end operations avoid shifting.
Q2. You must both count items and test membership millions of times. Structure?
Hashing gives O(1) average lookups and carries counts as values — the P1/P2 pairing from Part 23.
Q3. heapq.heappush and heappop both cost…
One sift per operation — logarithmic. The peek (heap[0]) is the O(1) part.
Q4. With a 10-million-step-per-second budget, which is acceptable for n = 100,000?
n log n at n = 100,000 is roughly 1.7 million steps — well inside budget. n² is 10 billion, hours of runtime.

Practice Problems

Practice 1 — The Slow Membership Check

🟢 L1
Problem

A loop scans items (100,000 strings) and for each one asks if item in seen_list, where seen_list is a growing list. What is the total cost, and which structure fixes it?

Show Hint

Look up x in some_list in the array table, then in the set table.

Show Approach

in on a list is O(n) per test → O(n²) overall. A set makes each test O(1) average → O(n) overall.

Show Solution
Python
def collect_uniques(items):
    seen = set()          # was: seen_list = []  → O(n^2) overall
    out = []
    for item in items:
        if item in seen:  # O(1) average now
            continue
        seen.add(item)
        out.append(item)
    return out

Practice 2 — Pick the Structure

🟡 L2
Problem

For each need, name the structure and the cost: (a) print tasks strictly in submission order; (b) always grab the most urgent task; (c) know instantly whether a username is taken.

Show Hint

Order → queue. Current best → heap. Membership → set. Match each verb to its table row.

Show Approach

(a) collections.deque — append and popleft, O(1). (b) heapq — push O(log n), pop O(log n), peek O(1). (c) set — in O(1) average.

Show Solution
Python
from collections import deque
import heapq

queue = deque()               # (a) arrival order
queue.append("task1")
next_task = queue.popleft()

urgent = []                   # (b) min-heap of priorities
heapq.heappush(urgent, (2, "task2"))
best = heapq.heappop(urgent)  # lowest number = most urgent

taken = {"ada"}               # (c) membership
print("ada" in taken)         # True — O(1) average

Practice 3 — Fix the O(n²) Palindrome Builder

🟡 L2
Problem

This code builds a doubled string char by char. Why is it O(n²), and what is the O(n) fix?

Python
def slow_double(s):
    out = ""
    for ch in s:
        out = out + ch + ch   # builds a brand-new string every time
    return out
Show Hint

Strings are immutable — what does out + ch + ch have to build, and how big is it by the end?

Show Approach

Each concatenation copies the whole current string: 1 + 2 + … + n characters = O(n²). Accumulate pieces in a list and join once.

Show Solution
Python
def fast_double(s):
    parts = []
    for ch in s:
        parts.append(ch + ch)   # tiny fixed-size strings only
    return "".join(parts)       # one O(n) copy

print(fast_double("abc"))   # aabbcc

Challenge Problem

Challenge — MinStack

🟠 L3
Problem

Design a stack where push, pop, top, and get_min are all O(1). A plain stack loses the minimum when the minimum is popped — remember it smarter.

Show Hint

Store something extra beside each value. What did the minimum look like just before this value was pushed?

Show Approach

Each stack entry carries (value, min below this point). Pushing recomputes the running minimum; popping reveals the previous minimum automatically — no rescans.

Show Solution
Python
class MinStack:
    def __init__(self):
        self.stack = []            # each entry: (value, min so far)

    def push(self, x):
        current_min = self.stack[-1][1] if self.stack else x
        self.stack.append((x, min(x, current_min)))

    def pop(self):
        return self.stack.pop()[0]

    def top(self):
        return self.stack[-1][0]

    def get_min(self):
        return self.stack[-1][1]

s = MinStack()
s.push(5); s.push(2); s.push(7)
print(s.get_min())   # 2
s.pop()              # removes 7
print(s.get_min())   # 2
s.pop()              # removes 2 — the minimum, and the old one returns
print(s.get_min())   # 5

Every operation is O(1); space doubles to store the running minimum per entry. This is “what information can I remember?” — Part 33 — turned into a data structure.

Revision Checklist

PART 33

How to Think

The habits and the five questions that turn a blank page into a plan — philosophy, but the practical kind.

Patterns solve problems, but patterns do not choose themselves. When a statement does not match any card in Part 23, what separates a solver from a guesser is a process of thinking — slow, boring, repeatable moves that reliably produce ideas. This part is that process. None of it is clever; all of it is what strong solvers actually do, in the order they do it.

The six rules of thinking

Each rule is cheap in the moment and expensive to skip. Read them once as philosophy, then treat them as a checklist.

Rule 1 — Observation before implementation

Code written before understanding is a guess with syntax. Spend the first minutes doing nothing but looking: restate the problem in your own words, work the examples by hand, and annotate what changes from one example to the next.

In practice: zero lines of code until you can explain the example’s answer out loud, step by step.

Rule 2 — Constraints before optimization

The constraints line is a verdict, not metadata. n ≤ 10⁵ forbids O(n²); n ≤ 20 invites 2ⁿ enumeration (Part 32). Designing an idea the budget kills wastes the whole session on a doomed plan.

In practice: translate every constraint into an allowed complexity before designing.

Rule 3 — Simple solution before clever solution

A brute force is not a failure — it is a plan B and a correctness oracle. Write it, watch what work it repeats, and let that repetition point at the optimization (Part 24 is this rule as a whole chapter).

In practice: say the O(n²) idea out loud first. It takes a minute and it de-risks everything after.

Rule 4 — Pattern before code

Name the pattern before typing. A named pattern imports a skeleton, a mental model, a known complexity, and a list of known mistakes — half the solution arrives pre-written (Part 23).

In practice: if you cannot name a pattern, that is information — fall back to rule 3 and hunt for repeated work.

Rule 5 — Dry run before submission

Executing your own code on paper with one small example — including one edge case — takes two minutes and catches the majority of bugs you would otherwise discover as “wrong answer on test 57”.

In practice: trace the loop with real values, write the variable states down, compare with the expected output.

Rule 6 — Complexity before acceptance

Passing the sample case says nothing about the hidden ones. Before declaring victory, count operations against the constraints again — the sample is tiny, the tests are not.

In practice: the last sentence you write about any solution is “Time O(?) · Space O(?)” — and it must match the budget.

Q1. Forty minutes, a fresh problem. What comes first?
Rule 1: observation before implementation. Understanding is the only input that makes every later step faster.

The mental toolkit — five questions

When no pattern announces itself, ask these five questions, in order. Each one points at a specific family of techniques. This is the toolkit you carry when the pattern library is silent.

Q1
“What work am I repeating?” → that repetition is what the optimization deletes
Q2
“What information can I remember?” → sets, maps, heaps, running values
Q3
“What structure does this problem naturally have?” → sorted? contiguous? nested? a network?
Q4
“Can sorting simplify the problem?” → neighbors become adjacent — if order does not matter
Q5
“Can I avoid recomputing something?” → prefix sums, windows, memoization
QuestionWhat it unlocksWhere the book treats it
Q1 · What work am I repeating?brute force → better → optimal thinkingPart 24
Q2 · What can I remember?seen set, frequency map, heap of candidatesPart 5, Part 17
Q3 · What structure is natural?two pointers, windows, stacks, graphs, intervalsPart 23
Q4 · Can sorting simplify?sorting + scan, greedy, intervalsPart 9, Part 19, Part 20
Q5 · Can I avoid recomputing?prefix sums, sliding windows, memoizationPart 8, Part 7

Worked example — five questions, one problem

Problem: given a string s, return the length of the longest substring with at most 2 distinct characters. Example: "eceba" → 3 (the substring "ece").

1

Observe (Rule 1). Restate: I need a contiguous stretch of the string where no more than two different letters appear, and I want the longest one. Check "eceba" by hand: "ece" uses {e, c} — length 3; "ceb" uses {c, e, b} — too many; "ba" uses {b, a} — length 2. Answer 3. Contiguous is already a structural fact worth circling.

2

Q1 — what work am I repeating? The naive plan checks every start index and scans forward, recounting the distinct characters each time. The same characters are recounted over and over — that is the repeated work.

3

Q2 — what can I remember? Q5 — what can I avoid recomputing? Remember a counts dictionary for the current stretch, and update it incrementally as the stretch moves: add one character on the right, remove one on the left. Nothing is ever recounted from scratch.

4

Q3 — what structure is natural? Contiguous substring + a condition on the whole stretch → this is the sliding window signature (Part 23, P4): grow on the right, shrink on the left while invalid.

5

Q4 — can sorting simplify? No — and knowing when a tool does not apply is thinking too. Sorting scrambles positions, and “substring” is a claim about positions. Order matters here; sorting is off the table.

6

Code (Rule 4), dry run (Rule 5), complexity (Rule 6). The window template writes itself; trace "eceba"; count the work — each character enters and leaves the window once, so O(n), well within any budget.

Python
def longest_two_distinct(s):
    counts = {}                 # char -> how many times it is in the window
    left = 0
    best = 0
    for right in range(len(s)):
        counts[s[right]] = counts.get(s[right], 0) + 1
        while len(counts) > 2:          # too many distinct chars: shrink
            counts[s[left]] -= 1
            if counts[s[left]] == 0:
                del counts[s[left]]     # a char at 0 count is OUT of the window
            left += 1
        best = max(best, right - left + 1)
    return best

print(longest_two_distinct("eceba"))   # 3  ("ece")
Dry Run — s = "eceba"
right=0 'e' → counts {e:1}                    → window "e",    best 1
right=1 'c' → counts {e:1, c:1}               → window "ec",   best 2
right=2 'e' → counts {e:2, c:1}               → window "ece",  best 3
right=3 'b' → counts {e:2, c:1, b:1} → 3 distinct!
             drop 'e' (left 0) → {e:1, c:1, b:1} still 3
             drop 'c' (left 1) → {e:1, b:1}  ✅ left=2, window "eb", best 3
right=4 'a' → counts {e:1, b:1, a:1} → 3 distinct!
             drop 'e' (left 2) → {b:1, a:1}  ✅ left=3, window "ba", best 3
answer: 3
Pattern Insight

Notice what actually produced the solution: not brilliance — three of the five questions. Q1 found the waste, Q2/Q5 found what to remember, Q3 named the pattern. And Q4 earned its keep by being correctly rejected. Five questions, applied honestly, are the idea-generating machine.

Q2. Which toolkit question does a hash map (or set) directly answer?
A hash map is memory: value → count, value → index, seen values. It converts remembered information into O(1) answers.

A dialogue with yourself

Strong solvers talk to themselves — quietly, but in full sentences. Here is the inner monologue for a second problem, “best profit from one buy and one later sell” with prices [7, 1, 5, 3, 6, 4]. The questions are the same; watch them transfer.

“One transaction: buy one day, sell a later day, maximize the difference. Must buy before selling. If prices only fall, the answer is 0 — do nothing.”

That restatement already killed two misreadings: no multiple transactions, and selling before buying is illegal. Writing these down is rule 1 — two minutes that shape everything.

  • Q1 — What work am I repeating? “For every buy day I was about to rescan all later days for the best sell. The future scan repeats.”
  • Q2 — What can I remember? “While walking left to right, the cheapest price seen so far — one number.”
  • Q3 — What structure is natural? “A single left-to-right scan; each day compares itself against remembered history. A running-minimum scan, not a window — the buy and sell need not be adjacent.”
  • Q4 — Can sorting help? “No! Sorting destroys the calendar. Buy-before-sell is an order claim. Rejected.”
  • Q5 — Avoid recomputing? “Yes — keep the minimum updated as I go instead of recomputing it per day.”

“One pass. Track the cheapest price so far; each day, check what selling today would earn against it. Keep the best.”

Python
def max_profit(prices):
    min_so_far = prices[0]     # cheapest price seen up to now
    best = 0                   # profit if we never trade
    for price in prices[1:]:
        best = max(best, price - min_so_far)  # sell today?
        min_so_far = min(min_so_far, price)   # or make today the new cheapest
    return best

print(max_profit([7, 1, 5, 3, 6, 4]))   # 5  (buy at 1, sell at 6)
Dry Run — prices = [7, 1, 5, 3, 6, 4]
price 7 → min_so_far 7, best 0
price 1 → best max(0, 1-7)=0 → min_so_far 1
price 5 → best max(0, 5-1)=4 → min_so_far 1
price 3 → best max(4, 3-1)=4 → min_so_far 1
price 6 → best max(4, 6-1)=5 ✅ → min_so_far 1
price 4 → best max(5, 4-1)=5 → min_so_far 1
answer: 5

“Matches the by-hand expectation — buy at 1, sell at 6. Also checked in my head: [9, 8, 7] returns 0, the do-nothing case.”

“Time O(n), space O(1) — fits any budget. The work that vanished: for each buy day, the rescan of all later days. What replaced it: one remembered number.

Where could this idea fail? Only if order stopped mattering — but order is the problem’s spine, and that is exactly why Q4 was rejected. Same five questions as the substring problem; different answers. That is what it means for a toolkit to transfer.”

See Part 3 for this problem’s full treatment with the min-so-far visual.

From confused to confident

Confusion is not a verdict — it is the first stage of a pipeline. Every move below is small, and none requires inspiration.

Confused — “I have no idea”
↓
Restate the problem in your own words
↓
Squeeze the examples by hand
↓
Find the work a brute force repeats (Q1)
↓
Match a structure / pattern (Q2, Q3)
↓
Write the skeleton, then the code
↓
Dry run + complexity check
↓
Confident — with receipts, not vibes

Building lasting intuition

Insight on Tuesday that is gone by Friday was entertainment, not learning. Intuition is built by how you revisit, not how much you read.

WhenWhat to doWhy it works
Same dayRe-derive the skeleton from a blank page — no peekingrecall strengthens memory far more than recognition
3 days laterRe-solve the problem from zero; compare with your old codespacing forces retrieval right before you would forget
1 week laterTeach it: explain the pattern aloud in five sentences, as if to a friendexposing gaps in your own explanation finds them cheaply
1 month laterMixed review: 3 old problems from different patterns, shuffledinterleaving trains recognition — the actual exam skill
  • Re-solving beats re-reading. Reading a solution feels like learning and leaves almost nothing behind; struggling to re-solve leaves a durable trace.
  • Keep a mistake journal. One line per failure: the pattern you missed and the keyword you ignored. Reread before practice sessions — your own mistakes are your highest-yield study material.
  • Name patterns out loud while solving anything, even easy problems. Naming is the bridge from “I have seen this” to “I will find this again”.
Tip — the five-minute post-mortem

After every problem — solved or failed — spend five minutes answering two questions: which habit from this chapter did I skip? and which question would have reached the answer fastest? Five minutes per problem compounds into the instinct people call “intuition”.

What You Learned

  • Six rules — observation, constraints, simple-first, pattern-before-code, dry run, complexity — form a checklist you can actually follow under pressure.
  • Five questions (repeating work? what to remember? natural structure? sorting? recomputation?) generate ideas when no pattern announces itself.
  • Knowing when a tool does not apply (sorting a calendar, windowing negatives) is thinking, not trivia.
  • Confusion is stage one of a pipeline, not a verdict — the flow from confused to confident is a list of small moves.
  • Intuition is manufactured by spacing, re-solving, teaching, and a mistake journal — not by volume of reading.

Pattern Recognition

The meta-pattern: when stuck, the problem is almost never “I need a smarter idea” — it is “I skipped a rule”. Audit your own process first: Did I restate? Did I check constraints? Did I name a pattern or admit none fits? Did I dry run? Nine times out of ten, the skipped rule was the missing idea.

Complexity Summary

No algorithms in this chapter — so the honest table is what each rule costs and what skipping it costs:

RuleCost when followedCost when skipped
1 · Observation first2–5 minutes of readinghours of code aimed at the wrong problem
2 · Constraints first30 seconds of translationa doomed O(n²) design at n = 100,000
3 · Simple solution firstone spoken sentenceno plan B, no correctness oracle
4 · Pattern before codeone lookup in Part 23rediscovering known skeletons under time pressure
5 · Dry run before submit2 minutes on paperwrong answer on hidden tests, after the timer
6 · Complexity before acceptone arithmetic checktime limit exceeded on the large input

Common Mistakes

  • Treating the rules as “advice for beginners” and skipping them on problems you fear — that is when they pay most.
  • Applying the five questions as a ritual without writing anything down; the writing is the thinking.
  • Confusing “I recognize this solution” (reading) with “I can produce it” (re-solving).
  • Post-mortems only on failures — the wins hide the habits that made them.

Mini Quiz

Q1. n can be 100,000 and your first idea is O(n²). Rule 2 says…
Constraints before optimization: about 10 billion steps is hours of runtime, so no constant factor saves it. The design must change.
Q2. Your code passes the provided sample. Rule 5 (dry run) and Rule 6 (complexity) say the next step is…
The sample is tiny and friendly; hidden tests are not. Two cheap checks stand between “works on the sample” and “works”.
Q3. For which problem would the question “Can sorting simplify this?” be correctly answered NO?
Buy-before-sell is a claim about order; sorting destroys the calendar. The other three are order-agnostic — sorting helps all of them.
Q4. Why re-solve a problem you already solved three days ago?
Spaced re-solving forces retrieval, which strengthens memory in a way that re-reading (recognition) cannot.

Practice Problems

Practice 1 — Five Questions, Zero Code

🟢 L1
Problem

Problem to think about: “Does any value appear twice in the list?” Write a written answer to each of the five toolkit questions. No code allowed until all five are on paper.

Show Hint

Q1 is about the naive plan (compare every pair). Q3 asks what kind of object the data is. Q4 asks whether positions matter here.

Show Approach

Model answers: Q1 — the naive plan re-scans for each value; Q2 — remember the values already seen (a set); Q3 — values with no useful order or contiguity; Q4 — sorting would also work (duplicates become neighbors) but costs O(n log n); Q5 — one pass with no rescanning at all.

Show Solution

The questions converge on the seen-set answer from Part 23 (P2):

Python
def has_duplicate(nums):
    seen = set()
    for x in nums:
        if x in seen:
            return True
        seen.add(x)
    return False

Practice 2 — Dialogue for a Sorted Pair

🟡 L2
Problem

Problem: “Given a sorted array and a target, return whether two different elements sum to the target.” Write your inner dialogue — all five questions — then code it.

Show Hint

Q3 is the star: the input is sorted, which is itself structure. What does a too-small sum tell you about the left end?

Show Approach

Q1 — brute force re-searches partners; Q2 — nothing needs remembering beyond two pointers; Q3 — sorted data → squeeze from both ends (P3); Q4 — sorting already done, do not re-sort; Q5 — each comparison eliminates one element forever.

Show Solution
Python
def pair_sum_sorted(nums, target):
    left, right = 0, len(nums) - 1
    while left < right:
        s = nums[left] + nums[right]
        if s == target:
            return True
        if s < target:
            left += 1     # sums with this left are all too small
        else:
            right -= 1    # sums with this right are all too big
    return False

print(pair_sum_sorted([1, 3, 4, 6, 8], 10))   # True  (4 + 6)

Practice 3 — Constraint Translation

🟡 L2
Problem

A statement guarantees n ≤ 100,000. Three candidate designs: (A) O(n²), (B) O(n log n), (C) O(n). Using the 10-million-steps-per-second budget, state which survive — with the arithmetic written out.

Show Hint

Compute the step counts, compare with 10 million. One sentence each; no hand-waving.

Show Approach

(A) 100,000² = 10 billion steps — about 17 minutes: dead. (B) about 1.7 million steps — well inside budget. (C) 100,000 steps — trivially fine.

Show Solution

B and C survive; A is eliminated before coding. That elimination is the entire value of rule 2: it redirects effort from “how do I make A faster in Python?” (hopeless) to “what repeated work does A do?” (answerable — see Part 24).

Challenge Problem

Challenge — The Silent Solve

🟠 L3
Problem

Take this problem and solve it without opening any other chapter: “Find the length of the longest substring without repeating characters.” Write your full inner dialogue — six rules and five questions — before writing code. Then, and only then, compare against the worked example in Part 7.

Show Hint

The substring claim makes it contiguous (Q3). The naive plan recounts the window for every start (Q1). What single object remembers the window’s characters (Q2)?

Show Approach

Your dialogue should have hit: restatement with a by-hand example; rejected sorting (positions matter); a window that grows right and shrinks left while a repeat exists; a seen set (or index map) as the window’s memory; a dry run on something like "pwwkew"; and the O(n) count — each character enters and leaves once.

Show Solution
Python
def longest_unique_substring(s):
    seen = set()
    left = 0
    best = 0
    for right in range(len(s)):
        while s[right] in seen:
            seen.remove(s[left])
            left += 1
        seen.add(s[right])
        best = max(best, right - left + 1)
    return best

print(longest_unique_substring("pwwkew"))   # 3

Grading your dialogue, not your code: if your five questions converged on the window before you coded, the toolkit worked — the code was just transcription. That is the whole point of this chapter.

Revision Checklist

PART 34

DSA Cheat Sheets

Twenty topics, twenty cards. The pages you screenshot, revisit, and eventually stop needing.

This part compresses the technical spine of the book — Parts 1 through 21 — into a single reference. Every card follows the same shape, so your eyes always know where to look: a facts table (operations, costs, rules), one template of ten lines or fewer that you should be able to type from memory, and trigger tags — the phrases that appear in problem statements when this is the card to open. If a card feels fuzzy rather than familiar, that is the signal to revisit its linked chapter: a cheat sheet is a reminder, not a lesson.

How to use these sheets

1

Recall first. Cover the card, open a blank file, and rebuild the table and template from memory. Guessing is the workout.

2

Peek to correct. Uncover one column at a time and mark only what you missed — the misses are your revision targets.

3

Rebuild tomorrow. Retrieval a day later is what moves a sheet into permanent memory; Part 35 turns that into a full schedule.

1 · Big-O — the cost ladder

Big-O Complexity measure before you code
ComplexityReads asWhere it shows upn = 10^5 → steps
O(1)constantdict / set lookup, arithmetic, len()1
O(log n)halvingbinary search, n //= 2 loops17
O(n)linearone pass over the input100,000
O(n log n)sort + scansorted(), merging sorted lists1,700,000
O(n²)nested loopschecking every pair10,000,000,000 ⚠️
O(2^n)all subsetsexhaustive recursion~10^30103 ⚠️

Rule of thumb: ~10^7 simple operations per second. At n = 10^5, an O(n²) solution needs about 17 minutes — read the constraints before designing anything.

Python
def count_shapes(nums, n):
    total = sum(nums)            # O(n) — one pass
    for i in range(n):           # O(n²) — every pair
        for j in range(n):
            total += 1
    while n > 1:                 # O(log n) — halves each step
        n //= 2
        total += 1
    nums.sort()                  # O(n log n)
    return total

count_shapes([1, 2, 3], 4) returns 24 — that is 6 (the pass) + 16 (the pairs) + 2 (the halvings).

count the loops n ≤ 10^5 in the constraints will this TLE? dict/set = O(1) lookup

Full chapter: Part 1 — Complexity Analysis · extended table: Part 32

2 · Arrays — the default container

Arrays & Lists O(1) ends · O(n) middles
OperationCodeCost
read by indexnums[i]O(1)
search by valuex in numsO(n)
append / pop at endnums.append(x) · nums.pop()O(1)
insert / pop at frontnums.insert(0, x) · nums.pop(0)O(n) ⚠️
insert / delete in middlenums.insert(i, x)O(n)
sortnums.sort()O(n log n)
slice of length knums[i:j]O(k)
Python
def best_in(nums):            # the one-pass "running value" template
    best = nums[0]
    for x in nums[1:]:
        if x > best:          # min → flip to <  ·  sum → best += x
            best = x
    return best

one pass, running value in-place = write pointer never mutate while iterating alias vs copy: nums[:]

Full chapter: Part 3 — Arrays & Lists

3 · Strings — read-only arrays

Strings immutable
Operation / factCodeCost
strings never change in places[0] = "x" → ❌ TypeError—
read one chars[i]O(1)
slice of length ks[i:j]O(k)
membershipch in sO(n)
split into partss.split()O(n)
join a list of parts"".join(parts)O(n)
build by concatenations += ch in a loopO(n²) ⚠️
char to codeord(ch) · chr(n)O(1)
Python
def clean(s):
    out = []
    for ch in s:              # strings are read-only —
        if ch.isalnum():      # so build into a list
            out.append(ch.lower())
    return "".join(out)       # join once — O(n)

build lists, join once two-pointer palindrome char counts in a dict enumerate for index + char

Full chapter: Part 4 — Strings

4 · Hashing — remember what you saw

Hashing: dict & set O(1) lookup
OperationCodeAvg cost
key present?k in dO(1)
read / writed[k] · d[k] = vO(1)
read with defaultd.get(k, 0)O(1)
deleted.pop(k)O(1)
seen setx in seenO(1)
frequency countd[x] = d.get(x, 0) + 1O(n) to build
Python
def two_sum(nums, target):
    seen = {}                  # value → index
    for i, x in enumerate(nums):
        if target - x in seen: # partner already passed?
            return seen[target - x], i
        seen[x] = i

fast lookup needed seen so far complement = target - x count first, then scan

Full chapter: Part 5 — Hashing · the pattern itself: Part 23

5 · Two Pointers — converging, writing, chasing

Two Pointers O(n) sweep
VariantSetupMovementClassic use
convergingl = 0, r = n - 1both move inwardpair sum on sorted, palindrome
read + writew = 0 (write), i scansi advances, w writes on keepremove duplicates, move zeroes — in place
fast + slowf = s = headfast moves at twice the speedmiddle, cycle detection

All three are O(n) — every element is visited a constant number of times — and O(1) extra space.

Python
def pair_sum(a, target):      # a is sorted
    l, r = 0, len(a) - 1
    while l < r:
        s = a[l] + a[r]
        if s == target:
            return l, r
        if s < target:
            l += 1            # sum too small → bigger left value
        else:
            r -= 1            # sum too big → smaller right value

sorted input in-place overwrite palindrome one pass, O(1) space

Full chapter: Part 6 — Two Pointers

6 · Sliding Window — a live segment

Sliding Window O(n)
KindSignal in the statementGrowShrink
fixed size k"every subarray of size k"add s[r]drop s[l] when window exceeds k
variable"longest / shortest / count valid"add s[r]while invalid: drop s[l]

Each index enters the window once and leaves once, so two pointers still mean O(n) — never O(n²).

Python
def longest_win(nums, k):     # longest window with sum ≤ k
    left, best, cur = 0, 0, 0
    for right in range(len(nums)):
        cur += nums[right]    # 1. add the new right element
        while cur > k:        # 2. shrink while invalid
            cur -= nums[left]
            left += 1
        best = max(best, right - left + 1)  # 3. record
    return best

longest_win([1, 2, 1, 0, 1], 3) returns 3 — the window [2, 1, 0].

contiguous subarray / substring longest · shortest · max sum brute O(n²) → O(n) define "valid" before coding

Full chapter: Part 7 — Sliding Window

7 · Prefix Sum — pay once, query free

Prefix Sum O(1) range sums
OperationCodeCost
buildpre = [0], then pre.append(pre[-1] + x)O(n)
sum of nums[i..j]pre[j + 1] - pre[i]O(1)
count subarrays with sum = kdict of prefix values seen so farO(n)
Python
nums = [2, 4, 1, 7]
pre = [0]                     # pre[i] = sum of first i elements
for x in nums:
    pre.append(pre[-1] + x)   # [0, 2, 6, 7, 14]
# sum of nums[i..j] = pre[j+1] - pre[i]
# nums[1..3] = 4 + 1 + 7 = pre[4] - pre[1] = 14 - 2 = 12

many range-sum queries sum of a subarray differences instead of re-adding pair with a dict for sum = k

Full chapter: Part 8 — Prefix Sum

8 · Sorting — make structure visible

Sorting O(n log n)
TaskCodeNote
sorted copy, original keptb = sorted(nums)—
sort in placenums.sort()returns None ⚠️
descendingnums.sort(reverse=True)—
order by a rulewords.sort(key=len)any function works
multi-criteriakey=lambda p: (p[0], -p[1])tuples compare left → right
cost of comparison sortingany correct sortO(n log n)
Python
words = ["pear", "fig", "apple"]
words.sort(key=len)                    # ['fig', 'pear', 'apple']
nums = [5, 1, 4]
nums.sort(reverse=True)                # [5, 4, 1]
pairs = [(1, 9), (1, 4), (0, 7)]
pairs.sort(key=lambda p: (p[0], -p[1]))  # [(0,7), (1,9), (1,4)]
copy = sorted(words)                   # ['apple', 'fig', 'pear'] — default order

sorted → two pointers / binary search / greedy / intervals key= defines the order sort() returns None

Full chapter: Part 9 — Sorting

9 · Binary Search — halve until certain

Binary Search O(log n)
FactDetail
preconditionsorted array — or any monotonic yes/no question
costn = 10^9 → about 30 probes
loop formwhile lo <= hi, then lo = mid + 1 / hi = mid - 1
first / last occurrenceon a match, keep probing the half you care about
on answersbinary-search the answer space, checking feasibility
Python
def bsearch(a, t):            # a is sorted → index or None
    lo, hi = 0, len(a) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if a[mid] == t:
            return mid
        if a[mid] < t:
            lo = mid + 1      # discard left half
        else:
            hi = mid - 1      # discard right half

sorted smallest value that works minimize the maximum answer stays inside [lo, hi]

Full chapter: Part 10 — Binary Search

Checkpoint 1 — which sheet?

You must report the 5 largest numbers from a stream of one million values, without sorting everything. Which card?
Sheet 16: a size-k heap answers top-k in O(n log k). Sorting all n would cost O(n log n) and hold every value for no reason.

10 · Stack — last in, first out

Stack O(1) ops
OperationCodeCost
pushst.append(x)O(1)
pop (last in)st.pop()O(1)
peek topst[-1]O(1)
empty?len(st) == 0O(1)
⚠️ front removalst.pop(0)O(n) — that is a queue job
Python
st = []
for ch in "abc":
    st.append(ch)              # push — the newest lands on top
while st:
    print(st.pop(), end="")    # pops c, then b, then a

Prints cba — the reverse of the arrival order, which is the whole point of LIFO.

matching pairs / brackets undo — most recent first next greater to the right nested structure

Full chapter: Part 11 — Stack

11 · Queue — first in, first out

Queue & Deque O(1) at both ends
OperationCodeCost
enqueue (rear)q.append(x)O(1)
dequeue (front)q.popleft()O(1)
peek frontq[0]O(1)
containercollections.deque — always—
⚠️ list.pop(0)shifts every remaining elementO(n)
Python
from collections import deque
q = deque(["a", "b"])          # items wait in arrival order
q.append("c")                  # enqueue at the rear — O(1)
first = q.popleft()            # "a" leaves first — FIFO, O(1)
while q:                       # the BFS skeleton:
    cur = q.popleft()          #   take from the front,
    print(cur, end=" ")        #   process, append neighbours

Prints b c — and that loop is exactly how BFS explores a graph, one layer at a time.

arrival order / first come, first served BFS frontier round-robin turns deque, never list.pop(0)

Full chapter: Part 12 — Queue & Deque

12 · Linked List — rewire, don't shift

Linked List O(1) rewire
OperationLinked listArray
reach the i-th itemO(n) walkO(1)
insert / delete at a known nodeO(1) rewireO(n) shift
insert at headO(1)O(n)
search by valueO(n)O(n)
reverseO(n), three pointersO(n)
Python
def reverse(head):
    prev, curr = None, head
    while curr:
        nxt = curr.next        # remember the rest
        curr.next = prev       # flip the arrow
        prev = curr            # step both pointers
        curr = nxt
    return prev                # new head

dummy node protects the head slow / fast: middle & cycles rewire pointers, never shift draw the arrows first

Full chapter: Part 13 — Linked List

13 · Recursion — shrink and trust

Recursion O(depth) stack space
PieceRule
base casesmallest input answered directly — write it first
recursive caseshrink the input, trust the smaller call, combine
spaceone stack frame per level → O(depth)
⚠️ missing base caseRecursionError at ~1000 frames
⚠️ repeated subproblemssolving the same call again and again can explode the cost (naive fib → O(2^n))
Python
def solve(n):
    if n <= 1:                 # 1) base case — smallest input
        return 1
    return n * solve(n - 1)    # 2) shrink + trust + combine

self-similar structure define the small case trust smaller calls depth costs memory

Full chapter: Part 14 — Recursion

14 · Backtracking — choose, explore, un-choose

Backtracking exponential by nature
PhaseCodeRule
choosepath.append(x)commit one decision
explorebt(next_state)recurse into the branch
un-choosepath.pop()⚠️ every choose needs its undo
recordres.append(path[:])copy — path keeps mutating
typical costO(2^n) subsets · O(n!) permutationsinherent to "all possibilities"
Python
def subsets(nums):
    res, path = [], []
    def bt(start):
        res.append(path[:])          # record a copy
        for i in range(start, len(nums)):
            path.append(nums[i])     # choose
            bt(i + 1)                # explore
            path.pop()               # un-choose
    bt(0)
    return res

generate all … subsets / permutations / combinations decision tree prune early, recurse less

Full chapter: Part 15 — Backtracking Basics

15 · Trees — recursion made visible

Trees O(n) traversal · O(h) BST ops
TraversalOrderTypical job
preordernode → left → rightcopy / snapshot a tree
inorderleft → node → rightsorted order out of a BST
postorderleft → right → nodechildren before parent (size, height)
level orderdepth by depth, with a queuerows, right-side views
any traversalevery node touched onceO(n)
Python
def size(node):                # every tree recursion has:
    if node is None:           # 1) base case: empty subtree
        return 0
    left = size(node.left)     # 2) trust the smaller calls
    right = size(node.right)
    return 1 + left + right    # 3) combine

None is the base case answer = f(left, right) BST: left < node < right, subtree-wide freeze queue size per level

Full chapter: Part 16 — Trees

16 · Heap — the priority structure

Heap / Priority Queue O(log n) push & pop
OperationCodeCost
heapify a listheapq.heapify(h)O(n)
pushheapq.heappush(h, x)O(log n)
pop minimumheapq.heappop(h)O(log n)
peek minimumh[0]O(1)
max-heap tricknegate on push, negate the result back—
top-kkeep the heap at size kO(n log k)
Python
import heapq
h = [7, 1, 5]
heapq.heapify(h)               # O(n) → min-heap, h[0] == 1
heapq.heappop(h)               # 1 — smallest out first, O(log n)
heapq.heappush(h, 2)           # O(log n)
# max-heap: push -x, pop, negate back
# top-k: keep the heap at size k → O(n log k)

k largest / smallest / kth most frequent merge many sorted streams Python heap = min-heap by default

Full chapter: Part 17 — Heap / Priority Queue

17 · Graph BFS / DFS — explore connections

Graph BFS & DFS O(V + E)
QuestionBFSDFS
exploreslayer by layerone branch deep, then backtrack
toolqueue (deque)recursion / stack
shortest path, unweighted✅ guaranteed❌ no guarantee
any path / all pathsfine✅ natural fit
connected components✅✅
visited set requiredyes — mark when enqueued / entered, or loops never end
Python
def bfs(g, start):             # g: node → neighbours (dict of lists)
    q = deque([start])         # deque imported from collections
    seen = {start}             # mark when enqueued, never after
    while q:
        cur = q.popleft()
        for nb in g[cur]:
            if nb not in seen:
                seen.add(nb)
                q.append(nb)
    return seen

network of connections grid = graph with 4 directions shortest → BFS explore all → DFS no visited → infinite loop

Full chapter: Part 18 — Graph Basics

18 · Greedy — sort, then commit

Greedy Thinking sort + sweep
StepRule
1 · sortcreate the choice order (by end, by size, by value)
2 · local choicetake what looks best now; never revisit
3 · verifyexchange argument + hunt a tiny killer case
⚠️ not always optimalcoins [1, 3, 4], target 6: greedy 4+1+1 (3 coins), optimal 3+3 (2 coins)
costO(n log n) — the sort dominates
Python
def max_kept(ivs):             # interval scheduling — keep the most
    ivs.sort(key=lambda x: x[1])   # earliest end first
    kept, end = 0, float("-inf")
    for s, e in ivs:
        if s >= end:           # compatible → take it
            kept += 1
            end = e
    return kept

maximum / minimum count sort by deadline / end safe local optimum always hunt a counterexample

Full chapter: Part 19 — Greedy Thinking Basics

19 · Intervals — sort, then sweep

Interval Problems sort first, always
ProblemSort byThen
merge overlappingstartextend the block with max(end) on overlap
can attend allstartclash check on adjacent pairs
minimum roomsstarts and ends separatelycount concurrent with two pointers
keep most / remove fewestendtake compatible intervals greedily
overlap testa.end ≥ b.start — whether touching counts is a per-problem convention ⚠️
Python
def merge(ivs):
    ivs.sort()                 # by start
    merged = [ivs[0]]
    for s, e in ivs[1:]:
        if s <= merged[-1][1]:     # overlap / touch → extend
            merged[-1][1] = max(merged[-1][1], e)
        else:
            merged.append([s, e])  # gap → new block
    return merged

input is ranges sort by start (or end) sweep once after sorting check the endpoint convention

Full chapter: Part 20 — Interval Problems

20 · Bit Manipulation — one-bit tricks

Bit Manipulation Basics O(1) checks
ExpressionMeaningExample (x = 12, binary 1100)
x & 11 → odd, 0 → even0
x >> 1halve (floor)6
x << 1double24
x & (x - 1)clear lowest 1-bit; == 0 → power of two8
a ^ bXOR — pairs cancel: a^a = 0, a^0 = a5 ^ 3 = 6
Python
x = 12                         # binary 1100
print(x & 1,                   # 0 → even
      x >> 1,                  # 6 → halved
      x & (x - 1),             # 8 → lowest 1-bit cleared
      5 ^ 3,                   # 6 → XOR adds without carry
      7 ^ 7)                   # 0 → pairs cancel

Prints 0 6 8 6 0.

parity / power of two single number — pairs cancel halve and double one-bit checks beat loops

Full chapter: Part 21 — Bit Manipulation Basics

Checkpoint 2 — which sheet?

"Given a string of brackets like ([{}]), decide whether every opener is closed by the matching closer, in the right order." Which card?
The most recently opened bracket must be closed first — last in, first out (Sheet 10). One pass: push openers, pop-and-match on closers.

What You Learned

  • All twenty core topics compressed into one consistent card: facts table, one template, trigger tags.
  • The trigger tags carry the real skill — recognition of the phrase → the pattern is what tests and interviews reward.
  • Every template is ten lines or fewer and runs as-is; if you cannot rebuild it from a blank file, the sheet is not yours yet.
  • Container costs at a glance (table below) prevent the classic silent slowdowns: pop(0), insert(0, …), s += ch in a loop.
  • These sheets pair with Part 28 — Problem-Solving Templates (full template set) and Part 32 — Complexity Master Table (extended version).

Pattern Recognition

Each tag row above is a compressed version of the decision tree in Part 23 — Core DSA Pattern Library: statement phrase → pattern → sheet. The workflow on an unseen problem never changes: read for structure, name the pattern, open the matching card, adapt its template. If two cards both seem to fit (window vs prefix sum, greedy vs intervals), that disagreement is valuable — it tells you exactly which Part 31 training loop to run next.

Complexity Summary

StructureAccessSearchInsert / deleteNote
list / arrayO(1) by indexO(n) unsorted · O(log n) sortedO(1) at end · O(n) front / middlethe default container
dict—O(1) avg by keyO(1) avgkeys → values
set—O(1) avgO(1) avgmembership only
stack (list)top O(1)O(n)push / pop O(1)LIFO
queue (deque)both ends O(1)O(n)both ends O(1)FIFO
heap (heapq)min O(1)O(n)push / pop O(log n)min first; negate for max
linked listO(n) walkO(n)O(1) rewire at a nodepointer-based
BSTO(h)O(h)O(h)h = height; inorder is sorted

Average cases; collisions and worst cases are out of beginner scope. Python implementation details can shift constants, never the class of cost.

Common Mistakes

  • Reading a sheet instead of recalling it — recognition only grows when retrieval hurts a little first.
  • Copying a template without being able to say what each pointer means and why it moves.
  • Memorizing the code but not the tags: the exam question is "which sheet?", never "dictate lines".
  • Trusting a template's O(n) label without checking the container: a pop(0) inside the loop quietly turns O(n) into O(n²).
  • Skipping the linked chapter when the card feels fuzzy — the sheet assumes the chapter's understanding.
  • Letting sheets go stale: re-derive each one during the revision passes scheduled in Part 35.

Mini Quiz

Q1. A sorted array and the question "do two values sum to S?" — which sheet?
Sorted input is the two-pointer trigger (Sheet 5): converge l and r, move the side whose sum is too small or too big. Hashing also works, but needs O(n) space and ignores the sorted gift.
Q2. "Longest substring in which every character is distinct" — which sheet?
"Longest" plus "contiguous substring" is the window signature (Sheet 6): grow right, shrink while a character repeats, track the best length.
Q3. "What is the minimum number of rooms to hold all these meetings?" — which sheet?
Meetings are [start, end] ranges (Sheet 19): sort starts and ends separately and count how many meetings are concurrent at any moment.
Q4. "Can account A reach account B through a chain of transfers?" — which sheet?
Accounts are nodes, transfers are edges — a reachability question (Sheet 17). BFS from A with a visited set; if you ever pop B, the answer is yes.
Q5. "List every ordering in which 4 tasks could be executed." — which sheet?
"Every ordering" means all permutations (Sheet 14): choose a task, explore the rest, un-choose — 4! = 24 orderings.

Practice Problems

34.1 Complexity Triage

🟢 L1 · Understand
Problem

Three snippets, three verdicts. For each: state the complexity, name the sheet that fixes it (or says "nothing to fix").
A. find a pair with sum t using two nested loops.
B. process items in arrival order with lst.pop(0) inside a loop.
C. while n > 1: n //= 2.

Show Hint

Sheet 1's table tells you the cost of each loop shape; the fix lives in a different sheet.

Show Approach

A: the inner loop re-searches for a value — remember what you saw. B: the shift is the cost — switch container. C: count the halvings.

Show Solution
Python
# A: O(n²) → Sheet 4 (Hashing): remember seen values
def pair_ok(nums, t):
    seen = set()
    for x in nums:
        if t - x in seen:
            return True
        seen.add(x)
    return False

# B: O(n²) → Sheet 11 (Queue): deque.popleft() is O(1)
from collections import deque
def process(seq):
    q = deque(seq)
    out = []
    while q:
        out.append(q.popleft())
    return out

# C: already O(log n) — no fix needed (Sheet 1, Sheet 9 family)

34.2 Template Recall — Two From Memory

🟡 L2 · Apply
Problem

Blank file, three minutes: type the converging two-pointer template and the binary search template from memory. Then diff against Sheets 5 and 9 and score yourself: 2 points per correct boundary, 1 point per correct pointer move.

Show Hint

Recite the invariants first: "everything left of l is settled, everything right of r is settled" and "the answer always stays inside [lo, hi]".

Show Approach

Write the while condition before the bodies — boundary bugs live in the condition, not the moves.

Show Solution
Python
def pair_sum(a, target):      # Sheet 5
    l, r = 0, len(a) - 1
    while l < r:
        s = a[l] + a[r]
        if s == target:
            return l, r
        if s < target:
            l += 1
        else:
            r -= 1

def bsearch(a, t):            # Sheet 9
    lo, hi = 0, len(a) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if a[mid] == t:
            return mid
        if a[mid] < t:
            lo = mid + 1
        else:
            hi = mid - 1

34.3 Name the Sheet — Four Statements

🟡 L2 · Recognize
Problem

For each statement, name the sheet and the one-line idea — no code allowed:
1. "Longest stretch of days where total rainfall stayed ≤ k."
2. "Does any route exist between two users in a friend graph?"
3. "Minimum number of platforms to fit all train stoppages."
4. "Return the k most frequent words."

Show Hint

Match the noun phrases: "stretch … total ≤ k", "route", "stoppages overlap in time", "k most frequent".

Show Approach

The tags are the matcher: contiguous + bound → window; connections → BFS/DFS; ranges + concurrency → intervals; k-ranked → heap.

Show Solution
  • 1 → Sliding Window (Sheet 6): grow right, shrink left while the total exceeds k.
  • 2 → Graph BFS / DFS (Sheet 17): BFS from one user with a visited set.
  • 3 → Intervals (Sheet 19): sort starts and ends separately, count concurrent trains — the minimum-rooms recipe.
  • 4 → Heap (Sheet 16) after a dict count (Sheet 4): size-k heap over the (count, word) pairs.

Challenge Problem

34.C The Blank-Page Reproduction

🟠 L3 · Recognize
Problem

Close the book. In one 25-minute session, from memory: (1) rebuild the O-ladder from Sheet 1 with the n = 10^5 column; (2) write any five templates, each ≤ 10 lines, from five different sheets; (3) write the trigger tags for each of those five sheets. Then reopen and grade.

Show Hint

Grade the invariants, not the comments: a perfect template with the wrong while condition scores 0 for that sheet.

Show Solution

Scoring rubric — repeat the drill after 1 day, 1 week, and 1 month (the Part 35 revision cycle):

  • O-ladder: 1 point per row for complexity + example, 1 point for a correct n = 10^5 value (7 rows max).
  • Templates: 2 points per template if it would run correctly; -1 for a wrong boundary or a missing undo / visited / shrink.
  • Tags: 1 point per sheet for at least two accurate trigger phrases.
  • Pass bar: ≥ 80% of available points. Below that, the missed items become your next recall-first targets — not reasons to re-read everything.

Revision Checklist

PART 35

Final DSA Roadmap

One path from "I know Python" to "I can solve DSA problems" — the order, the targets, the revision cycle, and the finish line.

Everything in this book exists on one path, and the order is not decorative: every stage hands the next one its tools. Hashing makes two pointers feel obvious; sorting makes binary search possible; recursion makes trees and graphs visible. This closing part assembles that path into a single view — the full sequence, a stage-by-stage study table, realistic practice targets, a spaced-repetition revision cycle, and the checklists that tell you honestly whether you are done. Work the path in order, at your own pace; the only rule is that a stage is finished when its checklist is true, not when its pages are read.

21
topics in one path
127
problems to solve along the way
3
revision passes per topic

The complete learning path

Python — fluent syntax (your starting point)
↓
Complexity · Part 1
↓
Arrays · Part 3
↓
Strings · Part 4
↓
Hashing · Part 5
↓
Two Pointers · Part 6
↓
Sliding Window · Part 7
↓
Prefix Sum · Part 8
↓
Sorting · Part 9
↓
Binary Search · Part 10
↓
Stack · Part 11
↓
Queue · Part 12
↓
Linked List · Part 13
↓
Recursion · Part 14
↓
Backtracking · Part 15
↓
Trees · Part 16
↓
Heap · Part 17
↓
Graphs · Part 18
↓
Greedy · Part 19
↓
Intervals · Part 20
↓
Basic Bit Manipulation · Part 21

The spine of the book: 21 topics, each one built from the tools of the stages above it.

The spine is not the whole book. Five parts travel alongside every stage — Part 2 (reading problems), Part 23 (pattern library), Part 25 (edge cases), Part 26 (debugging), and Part 27 (mistakes) — and four parts consolidate after it: Part 24 (brute → better → optimal), Part 29 (walkthroughs), Part 30 (practice mode), and Part 31 (pattern-recognition training). Return to them whenever a stage feels shaky.

Important

The order is the shortcut. Every stage makes the next one cheaper — skipping ahead does not save time, it costs the same material twice: once in confusion now, once in re-learning later.

Recommended order — stage by stage

StageWhat to learnWhy nowNeeds first
S0Python fluency — functions, dicts, sets, classesevery solution is written in it; syntax must cost you nothing—
S1Complexity (Part 1)a cost lens is required before you can judge any idea you will ever haveS0
S2Arrays (Part 3)the simplest data shape carries the richest habits: traverse, one-pass running values, in-place editsS1
S3Strings (Part 4)arrays with a read-only rule — teaches build-list-join disciplineS2
S4Hashing (Part 5)adds O(1) lookup to everything you already do; kills whole families of nested loopsS2
S5Two Pointers (Part 6)exploits sorted input and in-place rewrites — your first "structure, not speed" toolS2
S6Sliding Window (Part 7)two pointers that maintain a live segment; generalises the pair ideaS5
S7Prefix Sum (Part 8)window sums generalised to any range; pairs with hashing for sum-equals-kS2, S4
S8Sorting (Part 9)makes hidden structure visible — the enabler for everything belowS2
S9Binary Search (Part 10)needs the sorted / monotonic lens you just built in S8S8
S10Stack + Queue (Parts 11–12)first disciplined structures; the queue is also BFS's engine laterS2
S11Linked List (Part 13)pointer rewiring — a genuinely new way to think about dataS5, S10
S12Recursion (Part 14)shrinking calls; the foundation under trees, graphs, and backtrackingS1
S13Backtracking (Part 15)recursion that walks a decision tree with an undo buttonS12
S14Trees (Part 16)recursion made visible — a call tree you can see and touchS12, S13
S15Heap (Part 17)a tree stored in an array; handles all "top-k / kth" workS2, S14
S16Graphs (Part 18)recursion + queue working together — the everything-patternS10, S12
S17Greedy (Part 19)sort + local choice + proof habits; needs sorting maturity from S8S8
S18Intervals (Part 20)greedy applied to ranges — one recipe, many problemsS8, S17
S19Bit Manipulation (Part 21)cheap O(1) tools and the XOR cancellation trick — a finale, not a foundationS1

Practice targets — how many problems per topic

Counts are focused solves: problems you worked yourself, at least a brute force first, with a dry run afterwards. Ten understood problems beat fifty skimmed ones. Most of these problems exist inside the book (Part 29 walkthroughs, Part 30 practice mode, and every chapter's practice sets); pad from any problem source you like.

TopicSolveYou are ready to move on when…
Python (S0)0you can rebuild 3 small programs from memory — syntax is no longer a tax
Complexity5you guess a loop's Big-O correctly before counting
Arrays10one-pass running values and in-place write pointers feel like the default move
Strings6you build lists and join once, automatically, without thinking about it
Hashing8you reach for a dict or set before writing any nested loop
Two Pointers6you can set up converging and read/write variants from a blank line
Sliding Window6you can say exactly what "valid" means before coding the shrink
Prefix Sum5range questions make you write pre = [0] first, out of habit
Sorting6key= and reverse= are muscle memory; sort() vs sorted() never trips you
Binary Search8boundaries are right on the first attempt, including first/last occurrence
Stack6you see LIFO hiding in brackets, undo, and "most recent wins"
Queue5deque.popleft() is a reflex; BFS queue states hold no surprises
Linked List7you draw the arrows before you code; a dummy node appears by default
Recursion6base case comes first; you can trace a call stack by hand
Backtracking5choose → explore → un-choose, with the copy-on-record, every time
Trees10all four traversals feel natural recursively and iteratively
Heap5k-largest with a size-k heap, negation trick included, no notes
Graphs8visited is marked on enqueue; BFS for shortest, DFS to explore — automatically
Greedy5you can state the rule, the exchange argument, and hunt a killer case
Intervals6the sort key is chosen by the question; the endpoint convention gets checked
Bit Manipulation4x & 1, x >> 1, x & (x-1), and XOR cancellation — without notes

Total: 127 focused solves. At one focused hour a day, the whole path takes roughly 4–5 months — see the stage-budget table in the chapter end.

Revision strategy — the 1 day / 1 week / 1 month cycle

Learning a pattern and keeping it are two different problems. Memory for any skill decays on a predictable curve, and the counter-measure is well studied: retrieval at spaced intervals. Each time you force the knowledge out of your head — instead of re-reading it into your head — the curve flattens and the skill lasts longer. Every topic on the path gets three passes:

PassWhenWhat you doTime
Pass 11 day after learning the patternre-solve the 2 problems you struggled with most, from a blank file; glance at the cheat sheet only afterwards to correct15–20 min
Pass 21 week laterre-solve 1 problem cold + write the topic's template from memory before typing anything else20 min
Pass 31 month laterrecognition only: read 5 statements and name the pattern + complexity for each, Part 31 style — no code required30 min
Tip

Re-reading feels like knowing; retrieval is knowing. If a revision session feels comfortable, you are doing it wrong — the productive kind has you failing to recall, then checking, then fixing.

Tip

Keep a mistakes notebook — one line per bug or wrong turn, tagged with the part number (Part 26 explains the technique, Part 27 supplies the classic entries). Ten minutes before each revision pass, skim your own entries first: your past errors are the highest-value revision material you own.

Learn the pattern
→
Solve 5–10 problems
→
Revise 1d → 1w → 1m
→
Recognize faster

The learning loop — it repeats at every stage of the path above.

Pattern mastery checklist

The honest tracker. Tick an item only when you could demonstrate it on an unseen problem today — not when you remember reading about it. The groups follow the path; items follow the book.

Fundamentals

Arrays

Strings

Hashing

Core patterns

Trees & graphs

Craft — edge cases & debugging

Mindset

The Final Master Checklist

This is the sign-off. The tracker above is granular; this one is the book's own completion criteria, and it is only true when every line is true without notes, without hints, on a problem you have never seen.

Fundamentals

Arrays & strings

Hashing

Patterns

Everything together

Where you are now

Here is what has actually changed if you worked the path. Hand you a problem you have never seen — "longest run of equal temperatures", "do these two people share a group", "k most common words in a log" — and the blank file no longer stares back. You read the statement with questions running: what is the shape of the data, what work would a brute force repeat, what is worth remembering as I scan. You write the simple version without apology, watch why it is slow, and reach for the one structure that removes exactly that repeated work. You dry run it on paper, check the empty and single-element cases, and can say "O(n log n), because of the sort — and that fits the constraints." That is not a bag of memorized solutions; it is a repeatable way of thinking, and it transfers to problems this book never mentioned.

The two places to return to

When rust creeps in — and it will — go back to Part 23, the Core DSA Pattern Library, for the "which pattern?" decision tree, and to Part 31, Pattern Recognition Training, to keep the skill sharp. Then close them and go solve something.

What You Learned

  • The complete path — 21 topics in dependency order — from Python fluency to bit manipulation.
  • A stage table where every entry answers "why now" and names its prerequisites.
  • Practice targets with honest "ready to move on" tests: 127 focused solves across the journey.
  • A spaced-repetition revision cycle (1 day / 1 week / 1 month) built on retrieval, not re-reading.
  • A granular pattern-mastery tracker and the book's Final Master Checklist as the true finish line.
  • The two permanent anchors: the pattern library and pattern-recognition training.

Pattern Recognition

The roadmap is the pattern-recognition curriculum: patterns arrive in the order that makes each one recognisable, and recognition is trained at every stage by asking "what in the statement reveals this?" — the exact question Part 23's decision tree and Part 31's guessing challenges drill. From here on, your per-problem ritual is fixed: read for structure, name the pattern, open the matching cheat sheet, write the brute force, optimize, verify.

Complexity Summary

Stage groupProblemsRealistic time (1 focused hour / day)
S0–S1 · Python + complexity51 week
S2–S4 · Arrays, strings, hashing243 weeks
S5–S7 · Two pointers, window, prefix sum172.5 weeks
S8–S9 · Sorting + binary search142 weeks
S10–S11 · Stack, queue, linked list182 weeks
S12–S14 · Recursion, backtracking, trees213 weeks
S15–S16 · Heap + graphs132 weeks
S17–S19 · Greedy, intervals, bit manipulation152 weeks
Total127~4–5 months

Calendar estimates assume one focused hour daily plus the revision passes; slower is fine — stopping is the only failure mode.

Common Mistakes

  • Skipping to trees or graphs before recursion is solid — every downstream stage then wobbles.
  • Binge-reading without solving: the targets table counts solved problems, not pages seen.
  • Calling re-reading "revision" — if nothing was retrieved from memory, nothing was revised.
  • Moving on at zero solves because the next topic looks friendlier; readiness tests exist for a reason.
  • Dropping the whole roadmap after one bad week — the path tolerates slow, not stopped.
  • Ticking checklist items from memory of the chapter instead of a demonstration on an unseen problem.

Mini Quiz

Q1. Which should be learned first — sorting or binary search, and why?
The path's edges are dependencies: S9 (binary search) needs the sorted/monotonic lens built in S8. Sorting also feeds two pointers, greedy, and intervals.
Q2. Five weeks after Part 7 you cannot recall the window-shrink rule. Which revision move fixes that fastest?
Retrieval beats review: forcing the rule out of memory (and failing a little) is what strengthens it. Checking afterwards corrects precisely what was missed.
Q3. What is a realistic practice target for arrays before moving on?
Ten understood problems — brute first, dry run after — build the habits the next stages assume. One is exposure, fifty skimmed is theatre.
Q4. Why does backtracking sit immediately before trees on the path?
In backtracking you draw the decision tree yourself; in Part 16 the tree arrives as the input. Same mental model, one step more concrete — that is the ramp.
Q5. You are stuck on a brand-new problem and no pattern jumps out. What does the roadmap-trained solver do first?
Brute force first is the mindset habit from the tracker above — the repeated work it exposes is the evidence that names the pattern. Then brute → better → optimal becomes a story you can tell.

Practice Problems

35.1 Build Your Schedule

🟢 L1 · Understand
Problem

Turn the stage table into a personal calendar: pick your start date, assign each stage group its weeks from the chapter-end budget table, and mark where your three revision passes for arrays would land.

Show Hint

Work backwards from the total: 8 stage groups, roughly 17 weeks. Revision passes attach to topics, not to weeks — schedule them relative to when you finish each topic.

Show Approach

One calendar row per stage group; add three small recurring blocks (1 day / 1 week / 1 month) after each topic's completion date. Leave one buffer week per month — life happens.

Show Solution

A sample 5-month frame (1 hour/day): Week 1 S0–S1 · Weeks 2–4 arrays, strings, hashing · Weeks 5–7 pointers, window, prefix · Weeks 8–9 sorting + binary search · Weeks 10–11 stack, queue, lists · Weeks 12–14 recursion, backtracking, trees · Weeks 15–16 heap + graphs · Weeks 17–18 greedy, intervals, bits. Your dates will differ; the ordering and the revision blocks should not.

35.2 Revision Round 1 — Arrays

🟡 L2 · Apply
Problem

Today is "1 day later" for arrays. Run Pass 1: re-solve the two array problems you struggled with most (from Part 3 or the practice sets) in a blank file, no notes. Then check against the Array sheet and log every miss in your mistakes notebook.

Show Hint

"Struggled with most" means: needed the hint, or fumbled a boundary, or the first version failed its dry run. Those are the high-value problems.

Show Approach

Blank file → brute force first → dry run on paper → only then compare with the chapter. Log misses as one-liners tagged "p3".

Show Solution

The pass is done correctly when: (1) both problems were solved without opening the book first; (2) each miss produced one notebook line naming the cause (boundary, wrong tool, missed observation) — not just the symptom; (3) Pass 2 for arrays is now booked for a week from today.

35.3 Pattern-Recognition Sprint

🟡 L2 · Recognize
Problem

Open Part 31, pick five problems you have not seen, and give yourself two minutes each: name the pattern and the complexity — no code. Score 1 point for the right pattern, 1 for the right complexity.

Show Hint

Use the trigger tags from the cheat sheets as your matching vocabulary: sorted, contiguous, longest/shortest, k-largest, ranges, connections, "all possible".

Show Approach

For each statement, ask in order: shape of data → what the question optimises → which sheet's tags match. Only then say the complexity out loud.

Show Solution

Pass bar: 7/10. Below that, note which tag you missed — the fix is a targeted Part 31 loop on that pattern family, not a full re-read. Above 9/10: your recognition is ahead of your speed; repeat the sprint weekly and watch the time drop.

Challenge Problem

35.C The Cold Start

🟠 L3 · Recognize
Problem

One unseen problem, 40-minute cap, full loop on your own: given an array of positive numbers and a target target, return the length of the shortest contiguous subarray whose sum is at least target — or 0 if none exists. Example: nums = [2, 3, 1, 2, 4, 3], target = 7 → 2 (the window [4, 3]). Run every step: read, observe, pattern, brute, optimize, verify with a dry run and edge cases.

Input / Output

nums = [2, 3, 1, 2, 4, 3], target = 7 → 2 · nums = [1, 1], target = 5 → 0

Show Hint 1

The brute force checks every window: O(n²). What work does each inner loop repeat?

Show Hint 2

"Contiguous subarray" + an optimising question — which sheet's tags match? The numbers are positive: what happens to the sum when the window grows or shrinks?

Show Approach

Sliding window (Sheet 6): grow right, adding to a running sum; while the sum is ≥ target, the window is valid — record its length and shrink from the left to search for a shorter one. Positivity guarantees shrinking only decreases the sum, so the two pointers never need to move backwards → O(n).

Show Solution
Python
def shortest_geq(nums, target):
    left, cur, best = 0, 0, None   # None = no valid window yet
    for right in range(len(nums)):
        cur += nums[right]         # grow: add the new element
        while cur >= target:       # valid → try to shrink
            length = right - left + 1
            if best is None or length < best:
                best = length
            cur -= nums[left]
            left += 1
    return best if best is not None else 0
Dry Run — nums = [2, 3, 1, 2, 4, 3], target = 7
right=0 → [2]      sum=2  → not valid
right=1 → [2,3]    sum=5  → not valid
right=2 → [2,3,1]  sum=6  → not valid
right=3 → [2,3,1,2] sum=8 → valid, len=4, best=4
           shrink: drop 2 → [3,1,2] sum=6 → stop
right=4 → [3,1,2,4] sum=10 → valid, len=4, best stays 4
           shrink: drop 3 → [1,2,4] sum=7, len=3, best=3
           shrink: drop 1 → [2,4] sum=6 → stop
right=5 → [2,4,3] sum=9 → valid, len=3, best stays 3
           shrink: drop 2 → [4,3] sum=7, len=2, best=2 ✅
           shrink: drop 4 → [3] sum=3 → stop
answer: 2

Edge cases: no valid window → 0 (best stays None); single element ≥ target → 1. Time O(n) — each index enters and leaves the window once · space O(1).

Revision Checklist