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.
Stage
Question it answers
Typical output
Understand
What exactly is being asked?
one-sentence restatement
Examples
What does "correct" look like?
a hand-traced example, including the tricky one
Constraints
How big can the input get?
a target complexity, e.g. "needs O(n log n) or better"
Brute Force
What is the dumbest correct approach?
a slow-but-right plan, written down
Pattern Recognition
What have I seen that looks like this?
a candidate pattern (set? window? two pointers?)
Choose Data Structure
What must I store to avoid redoing work?
list / set / dict / stack choice
Optimize
Which repeated work can disappear?
the improved plan and what it no longer re-does
Complexity
Will it finish within the constraints?
time and space in O(...) notation
Implement
Can the plan become plain Python?
a first working version
Test
Does it survive samples and my own edges?
verified behavior, including empty/single-element inputs
Debug
Where does reality diverge from the plan?
a fixed version and the reason it broke
Reflect
Which 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.
Attempt for 10–15 focused minutes first. Writing a brute force counts as attempting. Staring does not.
Open Hint 1 only. Read it, close it, and re-attempt from what you now know.
Still stuck? Open the Approach. Then restate it in your own words before touching the code.
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:
Read the key idea first, not the code. One sentence: what does this solution remember that brute force kept forgetting?
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.
Name the repeated work that disappeared. That sentence is the actual lesson; the code is just its proof.
Close the book and re-implement from an empty file. No peeking. Struggle here is the learning.
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:
Class
One-line meaning
Typical example
O(1)
same work no matter the size
dictionary lookup
O(log n)
halve the space each step
binary search
O(n)
touch everything once
find the max
O(n log n)
sorting speed
sorted(nums) + one pass
O(n²)
every element vs every element
nested 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 SolutionPython
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 SolutionPython
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:
Keep the fastest-growing term. As n explodes, the biggest term decides everything else becomes irrelevant.
Drop constants. 3n, 100n and n/2 all grow the same way — linearly. They all get the label O(n).
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
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.
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 shape
Work
Complexity
one loop over n items
n
O(n)
two loops in sequence
n + n = 2n
O(n)
loop inside a loop
n × n
O(n²)
the loop variable halves (or ×2) each step
log n
O(log n)
a loop whose body itself sorts n items
n × n log n
O(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:
n
log₂ n
n
n log₂ n
n²
2ⁿ
10
3.3
10
33
100
1,024
100
6.6
100
664
10,000
≈10³⁰
1,000
10.0
1,000
9,966
1,000,000
≈10³⁰¹
10,000
13.3
10,000
132,877
100,000,000
≈10³⁰¹⁰
100,000
16.6
100,000
1,660,964
10,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):
Complexity
Largest n in ~1 second
That looks like…
O(1)
no limit
a dictionary lookup, whatever n is
O(log n)
effectively any n
binary search over 10¹⁸ items ≈ 60 probes
O(n)
≈ 10,000,000
one pass over ten million items
O(n log n)
≈ 500,000
sorting half a million items
O(n²)
≈ 3,000
all pairs among three thousand items
O(2ⁿ)
≈ 23
all subsets of 23 items (≈ 8.4 million)
O(n!)
≈ 10
all 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
Class
Grows like
Code shape that produces it
Safe n (Python, ~1 s)
O(1)
constant
indexing, dict/set lookup, arithmetic
any
O(log n)
halving
search space cut in half per step
any realistic
O(n)
linear
single pass
~10,000,000
O(n log n)
sort speed
sort + one pass
~500,000
O(n²)
all pairs
loop inside a loop (or in list inside a loop)
~3,000
O(2ⁿ)
doubling choices
take-it-or-leave-it recursion
~23
O(n!)
all orders
generating 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 SolutionPython
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 / phrase
What it tells you
sorted
binary 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 / substring
sliding window, or prefix sums (window needs a grow/shrink condition)
subsequence
order kept but gaps allowed → not a sliding window; think greedy or track-best-so-far
top k / k largest / k most frequent
heap — 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 characters
count characters with a dictionary and compare counts
in place / O(1) extra space
two pointers and swapping — no auxiliary structures
pair that sums to target
hash set of seen values (unsorted) / two pointers (sorted)
unique / distinct
set membership tracking
range sum, sum of elements i…j, many queries
prefix sums — precompute once, answer each query in O(1)
matching brackets / most recent / undo
stack — last in, first out
level by level / fewest steps in a grid
BFS (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:
Constraint
What it tells you
n ≤ 10⁵ or more
O(n²) is banned; target O(n log n) or O(n)
n ≤ 20
exponential 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 letters
at 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:
Reverse-engineer. For each example, explain why that output follows from that input. If you cannot, you have misread something.
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.
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.
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 says
Code 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:
What is given? One string of letters. Size unstated → assume up to ~10⁵ characters and budget accordingly.
What must be returned? A number — the length. Not the substring itself. Easy to solve the wrong problem here.
Constraints? Nothing explicit, so the 10⁵ assumption bans O(n²)-style re-checking; we need roughly O(n).
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.
Simplest solution? Examine every substring and test it for uniqueness. Vastly many substrings, each re-checked: far too slow.
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.
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. (contiguouslongestshrink 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 statement
Pattern to predict
Typical time
sorted + find a value
binary search
O(log n)
pair with a property
hash set / dict of seen items
O(n)
contiguous span + condition
sliding window
O(n)
top k / k most frequent
heap (or sort)
O(n log k)
best local choice
greedy
O(n log n)
frequency questions
dict counting
O(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:
Operation
Code
Cost
Why
Read / write by index
nums[i], nums[i] = v
O(1)
direct address computation
Append at the end
nums.append(v)
O(1) amortized
usually just drops into a spare slot
Insert / remove at the front
nums.insert(0, v), nums.pop(0)
O(n)
every later element shifts one slot
Search an unsorted list
x in nums
O(n)
must look at elements one by one
Length
len(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)
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.
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).
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.
Expression
What you get
Extra space
Original safe?
b = nums
alias — same list
O(1)
❌ writes leak through
b = nums[:]
shallow copy
O(n)
✅
b = nums[::-1]
reversed copy
O(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
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.
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)
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.
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
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.
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_farafter 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 keep
Updated by
Used in
running total (prefix sum)
total += x
subarray sums, range sum queries
min so far
min_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 → left
leaders (Drill 3.6)
sum ending here
current = 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).
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)
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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
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 / Problem
Time
Space
Access / write by index
O(1)
O(1)
Linear search, find max, is sorted
O(n)
O(1)
Frequency table / dedupe with a set
O(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 subarray
O(n)
O(1)
Merge two sorted arrays
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 times
n = 10,000
n = 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 "!!!".
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
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
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.
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.
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
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 SolutionPython
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
Problem
Time
Space
Traversal / frequency table
O(n)
O(k) distinct chars
Building via "".join
O(n)
O(n)
Building via += loop
O(n²) worst case
O(n)
Valid palindrome (two pointers)
O(n)
O(1)
Valid anagram (one dict)
O(n)
O(k)
First non-repeating character
O(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 search
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 n
Average 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()
Stores
key → value (count, index, group)
just the values
Lookup x in s
O(1) average
O(1) average
Answers
"how many?", "where?", "map to what?"
"have I seen it?"
Cost you accept
you must maintain the values
you 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:
n
Nested pairs (≈ n²/2)
Seen set (n lookups)
1,000
~500,000
1,000
100,000
~5,000,000,000
100,000
1,000,000
~500,000,000,000
1,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 manyhave I seenpair that sums tofirst uniqueappears 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.
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.
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 xbefore 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.
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
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.
"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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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…
Grab
Example
"how many of each?"
counts
dictionary (frequency map)
anagram, majority, first unique
"have I seen it? / does it exist?"
membership only
set
contains duplicate, happy number
"where is it? / how far apart?"
value → position
dictionary of index
Two Sum, nearby duplicate
"group / pair up by property"
key → list
dictionary of lists
group 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
Problem
Pattern
Time
Extra Space
Contains Duplicate
Seen Set
O(n) average
O(n)
Two Sum
Complement Lookup (value → index)
O(n) average
O(n)
Valid Anagram
Frequency Map
O(n)
O(1) — at most 26 keys
First Unique Character
Frequency Map
O(n)
O(1) — at most 26 keys
Majority Element
Frequency Map
O(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 seenbefore 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.
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
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:
Comparison
Move
What you safely discard
arr[left] + arr[right] < target
left += 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] == target
return
— 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:
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.
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.
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
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
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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
Situation
Move
Why it never skips the answer
sorted pair sum too small
left += 1
arr[L] + any remaining ≤ arr[L] + arr[R] < target
sorted pair sum too big
right -= 1
arr[R] + any remaining ≥ arr[L] + arr[R] > target
container, left line shorter
left += 1
keeping L cannot beat the area just measured
container, right line shorter or tie
right -= 1
same argument, mirrored
read element qualifies (keep-rule)
write it, both advance
invariant: prefix before write is settled
read element fails (keep-rule)
only read advances
read 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
Problem
Flavor
Time
Extra Space
Two Sum II (sorted pair sum)
opposite-direction
O(n)
O(1)
Remove Duplicates from Sorted Array
read/write
O(n)
O(1)
Move Zeroes
read/write
O(n)
O(1)
Container With Most Water
opposite-direction
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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
"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
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:
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 window
Variable-size window
Window size
given (k)
discovered by the condition
Left pointer
implicit — right − k
explicit, moves only while invalid (or while still valid, for "shortest")
Loop shape
one for, one add + one remove per step
for (expand) with an inner while (shrink)
Typical asks
"max/avg of every k-block"
"longest / shortest window satisfying X"
Time
O(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: subarraysubstringsize klongest…withoutshortest…with sumat most K distinct. An O(n) or O(n·k) target on an O(n²) brute force is the second
clue.
Complexity Summary
Problem
Window
State inside
Time
Space
Max sum subarray of size k
fixed
running sum
O(n)
O(1)
Longest substring without repeats
variable
set of chars
O(n)
O(alphabet)
Smallest subarray with sum ≥ k
variable
running sum
O(n)
O(1)
At most K distinct characters
variable
frequency map
O(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 SolutionPython
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.
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 SolutionPython
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 SolutionPython
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]
The formula is prefix[r + 1] − prefix[l], notprefix[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.
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]
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.
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:
best/longest/shortest live window under a condition
arbitrary range sums; counting subarrays by sum
How it moves
left and right sweep forward, window updated in place
one pass builds totals; queries are subtraction
State kept
current window's sum / set / Counter
the prefix array (or a dictionary of prefixes)
Negative numbers
"shrink while valid" breaks
works fine — arithmetic is sign-agnostic
Fixed size k
natural fit
also 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 SolutionPython
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 SolutionPython
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 SolutionPython
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 sumrunning totalpivot / equilibriumcount subarrays with sum k. If the question instead hunts a best window under a live
condition, go back to Part 7.
Complexity Summary
Task
Time
Extra Space
Build prefix array
O(n)
O(n)
One range-sum query after the build
O(1)
—
Running sum
O(n)
O(n) for the output
Pivot index
O(n)
O(1)
Count subarrays with sum k
O(n) average
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 exceptnums[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 SolutionPython
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()
Returns
a new sorted list
None
Original
unchanged
overwritten in place
Works on
any iterable (list, string, tuple, dict…)
lists only
Use when
you still need the original order
you 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.
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:
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
Sort
Best
Average
Worst
Space
Stable?
When to use
Bubble
O(n)*
O(n²)
O(n²)
O(1)
yes
never (teaching only)
Selection
O(n²)
O(n²)
O(n²)
O(1)
no
never (teaching only)
Insertion
O(n)
O(n²)
O(n²)
O(1)
yes
tiny or nearly-sorted inputs
Timsort (Python's sorted)
O(n)
O(n log n)
O(n log n)
O(n)
yes
everything 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.
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).
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
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 SolutionPython
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.
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 SolutionPython
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
Operation
Time
Extra 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 sort
O(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 partition
O(n), one pass
O(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.
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.
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 SolutionPython
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 case
Binary search, worst case
10
10
4
1,000
1,000
10
1,000,000
1,000,000
20
1,000,000,000
1,000,000,000
30
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 pastmid (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
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.
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
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_bound
upper_bound
meaning
target = 4
2
5
copies of 4 occupy indices 2..4
target = 5 (absent)
5
5
5 would be inserted at index 5
target = 0
0
0
before everything
target = 10
7 (= 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
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
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
Variant
Time
Space
The one changed line
Base search (any copy)
O(log n)
O(1)
return on ==
First occurrence
O(log n)
O(1)
on == → ans=mid, hi=mid-1
Last occurrence
O(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 position
O(log n)
O(1)
= lower bound, default n
Binary search on answer
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 operation
Python list code
What it does
Cost
push x
stack.append(x)
put x on top
O(1)
pop
stack.pop()
remove and return the top
O(1)
peek
stack[-1]
read the top, keep it
O(1)
is empty?
not stack
True when nothing is left
O(1)
size
len(stack)
number of items
O(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 pairssingle passmost 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
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 deletionsone 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 topstack[-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 · Recognizeearly-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.
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 SolutionPython
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 SolutionPython
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 SolutionPython
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
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
Task
Time
Extra space
push / pop / peek
O(1)
O(1)
valid parentheses
O(n)
O(n)
remove adjacent duplicates
O(n)
O(n)
next greater element
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 operation
deque code
Side
Cost
enqueue x
q.append(x)
rear
O(1)
dequeue
q.popleft()
front
O(1)
peek front
q[0]
front
O(1)
peek rear
q[-1]
rear
O(1)
is empty?
not q
—
O(1)
size
len(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.
Metric
Cost
Why
Time
O(n) queue operations
n iterations × O(1) deque ops (string building adds O(n log n) character work overall)
Space
O(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
simulationrequeueing
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.
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?
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 SolutionPython
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
Task
Time
Extra space
deque enqueue / dequeue / peek
O(1)
—
list.pop(0) on n items
O(n)
—
binary numbers 1..n
O(n) queue ops
O(n)
round-robin simulation
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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:
Operation
Array (Python list)
Singly linked list
Access the i-th element
O(1) — jump by index
O(n) — walk from the head
Insert / delete at the front
O(n) — shift everything
O(1) — rewire two pointers
Insert / delete at the end
O(1) amortized
O(n) walk — O(1) only if you also keep a tail pointer
Insert / delete at a known node
O(n) — still shifts
O(1) — rewire around it
Search by value
O(n)
O(n)
Memory per element
just the value
value + one pointer
Memory layout
one contiguous block
nodes 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
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:
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
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.nextafter 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 pointersone passO(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
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 fastandfast.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 algorithmO(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)
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 inputsdummy nodecompact
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
Task
Time
Extra space
traverse / search
O(n)
O(1)
insert at beginning
O(1)
O(1)
insert at end (no tail pointer)
O(n)
O(1)
delete by value
O(n)
O(1)
reverse
O(n)
O(1)
middle / cycle (slow & fast)
O(n)
O(1)
merge two sorted lists
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 ownn = 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:
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:
Call
What it does
Returns
factorial(4)
must compute 4 * factorial(3) → waits
4 * 6 = 24
factorial(3)
must compute 3 * factorial(2) → waits
3 * 2 = 6
factorial(2)
must compute 2 * factorial(1) → waits
2 * 1 = 2
factorial(1)
base case — answers immediately, no call
1
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:
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:
Call
Function calls made
Verdict
fib(10)
177
instant
fib(20)
21,891
still instant
fib(30)
≈ 2,692,537
seconds — 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.
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:
Situation
Reach for
Why
Simple linear pass: sum, max, count, filter
Loop
No self-similarity; a loop is simpler and has zero call overhead
Trees, nested folders, nested lists
Recursion
The 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)
Recursion
Halves of halves — self-similar by design
Linear task over a huge input (n in the millions)
Loop
Depth-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 SolutionPython
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 SolutionPython
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)
Time
Extra 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 building
O(n)
naive fib(n)
O(2ⁿ) — recomputation
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
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:
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.
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)
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
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
Problem
Output size
Time
Extra space
Subsets of n elements
2ⁿ
O(n · 2ⁿ)
O(n)
Permutations of n elements
n!
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/no
O(R · C) with permanent visited marks; the undo variant may re-explore cells via different paths
O(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.
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 SolutionPython
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.
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 SolutionPython
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 SolutionPython
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 SolutionPython
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)
Term
Meaning
In our tree
Root
the top node, no parent
1
Parent / Child
a node points to its children; they point back conceptually
2's children are 4 and 5
Leaf
a node with zero children
4, 5, 6
Edge
the link between parent and child
5 edges for 6 nodes (always n − 1)
Subtree
any node plus everything below it — a smaller tree
subtree rooted at 2 = {2, 4, 5}
Depth
distance (in edges) from the root to a node; root has depth 0
depth of 6 is 2
Height
the longest root-to-leaf path, counted in nodes here; empty tree = 0, single node = 1
height of the tree is 3
Binary tree
every node has at most 2 children
our 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
Traversal
Rule
Output on our tree
Typical use
Preorder
root → left → right
1 2 4 5 3 6
copy / serialize a tree
Inorder
left → root → right
4 2 5 1 3 6
sorted order in a BST (below!)
Postorder
left → right → root
4 5 2 6 3 1
delete, measure bottom-up
Level-order
level by level
1 2 3 4 5 6
shortest 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)
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))
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
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
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
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?
Operation
Balanced tree
Worst case (a straight chain)
search
O(log n) — half discarded per step
O(n) — the "tree" degenerates into a list
insert
O(log n)
O(n)
min / max
O(log n) — one leftmost / rightmost walk
O(n)
inorder (sorted output)
O(n) — every node once, any shape
O(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
Task
Time
Extra space
Any DFS traversal (pre/in/post)
O(n)
O(h) — recursion or explicit stack
Level-order traversal
O(n)
O(w) — widest level in the queue
Count / sum / max / search (plain tree)
O(n)
O(h)
Identical trees / invert / min depth
O(n)
O(h)
BST search / insert / min / max
O(h) = O(log n) balanced, O(n) chain
O(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 SolutionPython
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 SolutionPython
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.
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 SolutionPython
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
Operation
heapq code
Time
What you get
Insert
heapq.heappush(h, x)
O(log n)
nothing (heap updated in place)
Remove smallest
heapq.heappop(h)
O(log n)
the smallest value
Look at smallest
h[0]
O(1)
the smallest value, heap unchanged
Build from a list
heapq.heapify(lst)
O(n)
rearranges lst into a valid heap, in place
Size
len(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.
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.
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 SolutionPython
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 SolutionPython
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
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 / pattern
Time
Extra space
Push / pop
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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
Question
Adjacency list (dict of lists)
Adjacency matrix (n × n)
Memory
O(V + E) — one slot per node + per edge
O(V²) — always, even for few edges
“Is (u, v) an edge?”
O(degree of u) — scan its list
O(1) — direct lookup
Visit all neighbours of u
O(degree)
O(V) — even if u has 1 neighbour
Best for
sparse graphs — nearly every real problem
dense 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).
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
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.
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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
Given a graph and a start node, return the set of all nodes reachable from it.
Show SolutionPython
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
Task
Time
Extra space
BFS traversal
O(V + E)
O(V)
DFS traversal
O(V + E)
O(V)
Connected components
O(V + E)
O(V + E)
Count islands (R × C grid)
O(R · C)
O(R · C)
BFS shortest path, unweighted
O(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 < colsbefore 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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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:
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.
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.
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)
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
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 > furthestafter 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 SolutionPython
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 SolutionPython
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
Problem
Time
Extra space
Activity selection (max non-overlapping)
O(n log n)
O(1)
Assign cookies
O(n log n + m log m)
O(1)
Jump game (furthest reach)
O(n)
O(1)
Minimum coins, greedy-valid system
O(c log c) for c coin types
O(1)
Can attend all meetings
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
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)
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 intonew (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]
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])
[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]
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
Problem
Sort key
Time
Space
Merge intervals
start
O(n log n)
O(n) output
Insert interval (input pre-sorted)
— (already sorted)
O(n)
O(n) output
Can attend all meetings
start
O(n log n)
O(1)
Minimum rooms (count concurrent)
starts + ends separately
O(n log n)
O(n)
Min removals / max keeps
end
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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)
1
1
0
0
1
0
1
0
Place value
128
64
32
16
8
4
2
1
Contributes
128
64
0
0
8
0
2
0
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.
Expression
In binary
Result
12 & 10
00001100 & 00001010
00001000 = 8
9 & 6
00001001 & 00000110
00000000 = 0
7 & 1
00000111 & 00000001
00000001 = 1 (odd)
10 & 1
00001010 & 00000001
00000000 = 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
Expression
In binary
Result
12 | 10
00001100 | 00001010
00001110 = 14
9 | 6
00001001 | 00000110
00001111 = 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.
Expression
In binary
Result
12 ^ 10
00001100 ^ 00001010
00000110 = 6
9 ^ 6
00001001 ^ 00000110
00001111 = 15
5 ^ 5
00000101 ^ 00000101
00000000 = 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:
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.
Expression
Meaning
Result
3 << 1
3 * 2
6
5 << 2
5 * 4
20
1 << 3
2^3
8
Right shift — a >> k
Each position halves the value (rounded down). a >> k = a // 2^k.
Expression
Meaning
Result
12 >> 1
12 // 2
6
13 >> 2
13 // 4
3 (13.25 rounds down)
1 >> 1
1 // 2
0
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).
Operation
Python
Result with a=12, b=10
Use it for
AND
a & b
8 (00001000)
keep bits present in both; test a single bit
OR
a | b
14 (00001110)
turn bits on
XOR
a ^ b
6 (00000110)
flip bits; cancel pairs
NOT
~a
-13 (rule: -(a+1))
build "all ones except k" to clear a bit
Left shift
a << 1
24 (= a * 2)
multiply by 2^k; build masks 1 << k
Right shift
a >> 2
3 (= a // 4)
divide by 2^k; drop low bits
Five identities that solve most bit problems
Identity
What it tells you
Example
n & 1
the last bit → odd (1) or even (0)
7 & 1 = 1, 10 & 1 = 0
n & (n - 1)
n with its lowest set bit removed
12 & 11 = 8
x ^ x = 0
equal values cancel
9 ^ 9 = 0
x ^ 0 = x
zero is neutral
9 ^ 0 = 9
(n >> k) & 1
the 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
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:
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 SolutionPython
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 SolutionPython
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)
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 / operation
Time
Space
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 bit
O(1)
O(1)
Odd/even via n & 1
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
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).
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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
Tool
Time
Notes
GCD (Euclid)
O(log min(a, b))
numbers shrink at least by half every two steps
LCM via GCD
O(log min(a, b))
one GCD call
Prime check
O(sqrt(n))
odd divisors only
Sieve of Eratosthenes
O(n log log n)
space O(n)
All factors
O(sqrt(n))
collected in pairs
Digit sum / reverse / palindrome
O(number of digits)
= O(log n)
Fast power
O(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 SolutionPython
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 SolutionPython
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.
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 SolutionPython
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 occurrencesmost frequentanagramhow many timesfrequencysame 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 mapvalue → index — the Two Sum workhorse.
Grouping mapkey → 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
duplicateseen beforeuniquedistinctcontainspair 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 mapvalue → 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 arraypair with target sumpalindromereversein-placetwo 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.
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
contiguoussubstringsubarraylongestshortestat most kno repeatswindow
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).
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 sumsum from i to jsubarray sumrunning totalcount subarraysmany 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 matterany orderclosestminimum differenceduplicatescan 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).
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
sortedO(log n)first / last occurrenceminimum speed / capacitysearchboundary
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.
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).
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 orderfirst come first servedprocess taskslevel by levelsimulationround 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.
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 listcyclemiddlekth from the endO(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
nestedtreedefined in terms of itselfeach node / each levelrecursively
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 possiblegenerate everysubsetspermutationscombinationseach 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.
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.
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
connectedreachableislandscomponentsexplore allcount 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 largestkthtop kmost frequentk closestmerge 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.
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 ofmaximum number ofat mostassignschedulefewest
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.
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.
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.
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 twiceexcept onepower of twoodd / evenbinaryXORset 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 ||.
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.
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
Pattern
Typical time
Typical space
P1 Frequency Map
O(n)
O(k) distinct items
P2 Seen Set
O(n)
O(n)
P3 Two Pointers
O(n) (+ sort if needed)
O(1)
P4 Sliding Window
O(n)
O(k)
P5 Prefix Sum
O(n) build, O(1)/query
O(n)
P6 Sorting + Scan
O(n log n)
O(1)–O(n)
P7 Binary Search
O(log n)
O(1)
P8 Stack
O(n) total
O(n)
P9 Queue
O(n) total
O(n)
P10 Fast/Slow Pointer
O(n)
O(1)
P11 Recursion
O(calls)
O(depth) stack
P12 Backtracking
O(2ⁿ)–O(n · n!)
O(n) + output
P13 BFS
O(V + E)
O(V)
P14 DFS
O(V + E)
O(V) + depth
P15 Heap / Top-K
O(n log k)
O(k)
P16 Greedy
O(n log n)
O(1)–O(n)
P17 Intervals
O(n log n)
O(n)
P18 Bit Manipulation
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
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] ✅
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 seenbefore 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.
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]
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
Problem
Brute
Better
Optimal
What disappeared
Two Sum
O(n²)
—
O(n) hash map
complement re-scans
Max subarray sum
O(n²)
O(n) prefix view
O(n) Kadane
overlapping window sums
Sorted pair sum
O(n²)
O(n log n) binary search
O(n) two pointers
unruled-out pair checks
Contains duplicate
O(n²)
O(n log n) sort+scan
O(n) set
all-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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
# 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.
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
Skill
Cost
Payoff
Running the 10-item checklist before submitting
~2 minutes
catches most wrong-answer verdicts
Testing on [] and [x] and [x, y]
seconds
kills categories 1-3 and most crashes
Negatives + zero test input
seconds
kills accumulator initialization bugs
Minimal failing input (shrink it)
minutes
turns 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 SolutionPython
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?
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 SolutionPython
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 SolutionPython
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
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
Step
Watch: i
Expected total
Actual total
Verdict
start
—
0
0
✅
process index 0 (value 4)
0
4
never happens — i starts at 1
❌ first divergence
process index 1 (value -1)
1
4
0
❌
process index 2 (value 6)
2
10
6
❌
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 move
Cost
When it pays off
Labeled print of watch variables
seconds
wrong answers, hangs, pointer logic
Expected vs actual table
minutes
any wrong answer, finds first divergence
Indented recursion trace
minutes
missing base cases, wrong returns
Shrink to minimal failing input
minutes
anything — makes every other move easier
One-hypothesis-at-a-time fixing
discipline
prevents 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 SolutionPython
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.
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 n
Comfortable complexity
Typical tools
Death row
n <= 10
anything, even O(n!)
brute force, recursion
—
n <= 1,000
O(n²)
nested loops, DP tables
O(2^n), O(n!)
n <= 100,000
O(n log n) or O(n)
sort, heap, hash map, two pointers
O(n²)
n <= 10,000,000
O(n)
single pass, sieve
O(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 SolutionPython
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 SolutionPython
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 passvisit every elementO(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?".
countmost frequentanagram
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.
duplicateseen beforemembership
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.
sortedpair sumpalindromeO(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 placeremove / move elementstwo 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 kcontiguousmax / 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 / shortestsubarray or substringgrow 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 sumsubarray sumprecompute
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).
sortedsearchO(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 bracketsundonested 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 orderlevel by levelline 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 listcurr = 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.
insertnew headorder 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.
unlinkdummy noderewire .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 rightrecursionevery 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 recursionslide leftstack 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 orderqueueper-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".
queuevisited setshortest
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.
grid4 directionsbounds 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 allrecursionvisited 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 / largestrepeatedlypriority
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 largesttop kO(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 subsetsall orderingstry 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 timesoverlapsort 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
Template
Time
Extra Space
Traversal / frequency map / set lookup
O(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 sum
O(n) build, O(1) per query
O(n)
Binary search
O(log n)
O(1)
Stack / queue / deque
O(n) total ops
O(n)
Linked list ops
O(n)
O(1)
Tree traversals
O(n)
O(h) stack, O(w) queue
BFS / DFS on a graph
O(V + E)
O(V)
Size-k heap
O(n log k)
O(k)
Backtracking (subsets)
O(2ⁿ) states — small n only
O(n) recursion
Interval merge
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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?
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 SolutionPython
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.
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
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 SolutionPython
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 SolutionPython
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
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 SolutionPython
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).
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 SolutionPython
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
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 SolutionPython
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 SolutionPython
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 SolutionPython
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).
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 SolutionPython
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 SolutionPython
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.
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 SolutionPython
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.
"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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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?
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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?
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 SolutionPython
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 SolutionPython
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
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 SolutionPython
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 SolutionPython
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.
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 SolutionPython
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.
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 SolutionPython
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?
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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?
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 SolutionPython
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 group
Typical optimal time
Typical extra space
Hashing
O(n)
O(n)
Two Pointers
O(n)
O(1)
Sliding Window
O(n)
O(k)
Prefix Sum
O(n) build, O(1)/O(1)-per-query answers
O(n)
Sorting & Binary Search
O(n log n) / O(log n)
O(1)–O(n)
Stack & Queue
O(n)
O(n)
Linked List
O(n)
O(1)
Trees
O(n)
O(h)
Heap
O(n log k)
O(k)
Graphs BFS/DFS
O(V + E) = O(rows × cols) on grids
O(V)
Greedy & Intervals
O(n log n)
O(1)–O(n)
Bits & Math
O(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 SolutionPython
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.
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)
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)
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 linesPython
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 linesPython
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 linesPython
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 linesPython
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 SolutionPython
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).
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 SolutionPython
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 SolutionPython
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
Section
Items
Suggested points each
Max
A — Concepts
5 quizzes
1
5
B — Predict the Output
5 quizzes
1
5
C — Find the Bug
4 reveals
2 (1 if you needed the fix line)
8
D — Complexity
4 quizzes
1
4
E — Trace
3 dry runs
2 (all steps written)
6
F — Fill the Code
4 reveals
2
8
G — Name the Pattern
4 quizzes
1
4
H — Full Problems
3 cards
4 (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 checked
What a correct answer demonstrates
Predict the output
You execute code in your head, honoring order of operations and mutation
Find the bug
You test edge cases mentally (empty input, negatives, single element)
Complexity check
You count work per element/pointer, not per line
Trace the algorithm
You maintain state (pointers, stacks, running sums) without drift
Name the pattern
The clue-word reflex from Part 28 is becoming automatic
Full problems
Attempt → 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 SolutionPython
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 SolutionPython
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 SolutionPython
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.
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.
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.
"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)
Hashing
Fast existence checks and complement lookups
"sorted array" + "pair" + "O(1) space"
Two Pointers
Sorted ends let you discard one candidate per step
"longest/shortest contiguous stretch"
Sliding Window
Grows and shrinks; each element enters once
"subarrays that sum to…" (esp. with negatives)
Prefix Sum
Running sums make every stretch one subtraction or lookup
"sorted" + "O(log n)" / a bounded comparison count
Binary Search
Halving is the only O(log n) game in town
"most recent" / "undo" / "nested" / "matching"
Stack
Last-in-first-out is the required discipline
"in arrival order" / "level by level"
Queue
First-in-first-out preserves processing order
"explore everything, order irrelevant"
DFS / BFS
Reachability with a visited set
"shortest path / fewest steps" (unweighted)
BFS
Layer-by-layer expansion reaches the goal at minimal distance
A size-k (or extract-max) heap tracks the current best cheaply
"make the local best choice" + a single resource
Greedy
Sort into choice order; verify with the exchange test
"[start, end] pairs" / "overlap" / "how many at once"
Intervals
Sort the endpoints and sweep
"left/right children" / "subtree"
Tree Recursion
The recursive structure mirrors the recursive solution
"all subsets / orderings / configurations"
Backtracking
Choose, 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
Pattern
Typical time
Signature constraint it satisfies
Hashing
O(n)
One pass; extra O(n) memory allowed
Two Pointers
O(n)
Sorted input, O(1) extra space
Sliding Window
O(n)
Contiguous + positive/monotonic sums
Prefix Sum
O(n)
Range or subarray sums, negatives allowed
Binary Search
O(log n)
Sorted or monotonic answer space
Stack
O(n)
Most-recent-first; next-greater questions
Queue / BFS
O(V + E)
Unweighted shortest path; level order
DFS / Tree Recursion
O(n)
Explore all; subtree-shaped subproblems
Heap
O(n log k)
Running top-k or repeated max extraction
Greedy / Intervals
O(n log n)
One resource; start/end pair data
Backtracking
O(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
Operation
Code
Cost
Why
Access by index
nums[i]
O(1)
address arithmetic — jump straight to slot i
Search (unsorted)
x in nums
O(n)
must check items one by one
Append at end
nums.append(x)
O(1) amortized
occasionally Python copies into a bigger block
Pop from end
nums.pop()
O(1)
remove the last slot, nothing shifts
Insert at front
nums.insert(0, x)
O(n)
every element shifts right
Pop from front
nums.pop(0)
O(n)
every element shifts left
Insert / delete in middle
nums.insert(i, x) / del nums[i]
O(n)
everything after i shifts
Slice of k items
nums[a:b]
O(k)
copies k elements
Sort
nums.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
Operation
Code
Cost
Notes
Lookup by key
d[k], k in d
O(1) avg
worst O(n) on pathological hash collisions
Insert / update
d[k] = v
O(1) avg
Delete
del d[k], d.pop(k)
O(1) avg
check k in d or use .get first
Iterate keys / values
for 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
Operation
Code
Cost
Notes
Membership
x in s
O(1) avg
the whole reason seen-sets beat lists
Add
s.add(x)
O(1) avg
no-op if already present
Remove
s.remove(x) / s.discard(x)
O(1) avg
remove raises if missing, discard does not
Union / intersection
a | b, a & b
O(len(a) + len(b))
Stack (a list used as one)
Operation
Code
Cost
Push
stack.append(x)
O(1) amortized
Pop
stack.pop()
O(1)
Peek top
stack[-1]
O(1)
Size / empty
len(stack), not stack
O(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
Operation
Code
Cost
Notes
Enqueue (back)
q.append(x)
O(1)
on collections.deque
Dequeue (front)
q.popleft()
O(1)
the reason BFS queues use deque
Push front
q.appendleft(x)
O(1)
double-ended bonus
Peek either end
q[0], q[-1]
O(1)
Index in middle
q[i]
O(n)
⚠️ deque is not a random-access array
Dequeue via list
lst.pop(0)
O(n)
⚠️ shifts everything — never in a loop
Heap (heapq — a min-heap)
Operation
Code
Cost
Notes
Peek best
heap[0]
O(1)
the minimum — heapq is a min-heap
Push
heapq.heappush(heap, x)
O(log n)
one sift-up
Pop best
heapq.heappop(heap)
O(log n)
one sift-down
Build from a list
heapq.heapify(lst)
O(n)
cheaper than n pushes
Search arbitrary item
x in heap
O(n)
heaps answer “best?”, not “where is x?”
Linked List
Operation
Cost
Notes
Traverse / search
O(n)
no indexes — follow the arrows
Insert at head
O(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 value
O(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
Operation
Average (balanced-ish)
Worst (degenerate)
When the worst happens
Search
O(log n)
O(n)
tree degenerates into a linked list
Insert
O(log n)
O(n)
inserting already-sorted data
Delete
O(log n)
O(n)
same degradation
In-order traversal
O(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 for
Because
Fast membership: “have I seen x?”
set
O(1) average lookups
Count / associate: “how many, or where?”
dict
O(1) average, keeps the value too
Process in arrival order (FIFO)
collections.deque
O(1) at both ends
Last-in-first-out (LIFO)
list (append / pop)
O(1) at the end, nothing else needed
Repeatedly take the current best
heapq
O(log n) push/pop, O(1) peek
Sum of arbitrary ranges, many queries
prefix-sum list
build once O(n), then O(1) per query
Read by index, cheap at the end
list
O(1) access, O(1) append/pop
Frequent inserts/removals at both ends
collections.deque
O(1) both directions
Ordered data + search by position
sorted list + bisect
O(log n) search; inserts cost O(n) to shift
Keep only the k best seen so far
heapq of size k
O(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.
n
O(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
Structure
Its superpower
Its weakness
list
O(1) index, O(1) end ops
O(n) front/middle ops, O(n) search
dict / set
O(1) avg lookup, insert, delete
worst-case O(n) on collisions; no ordering
deque
O(1) both ends
O(n) middle access
heap
O(1) peek, O(log n) push/pop
O(n) arbitrary search; min-only in Python
linked list
O(1) splice once positioned
O(n) to reach any position
BST
O(log n) average search/insert/delete
O(n) worst case without balancing
sorted list + bisect
O(log n) search position
O(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 SolutionPython
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 SolutionPython
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 SolutionPython
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, andget_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 SolutionPython
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
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.
When
What to do
Why it works
Same day
Re-derive the skeleton from a blank page — no peeking
recall strengthens memory far more than recognition
3 days later
Re-solve the problem from zero; compare with your old code
spacing forces retrieval right before you would forget
1 week later
Teach it: explain the pattern aloud in five sentences, as if to a friend
exposing gaps in your own explanation finds them cheaply
1 month later
Mixed review: 3 old problems from different patterns, shuffled
interleaving 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:
Rule
Cost when followed
Cost when skipped
1 · Observation first
2–5 minutes of reading
hours of code aimed at the wrong problem
2 · Constraints first
30 seconds of translation
a doomed O(n²) design at n = 100,000
3 · Simple solution first
one spoken sentence
no plan B, no correctness oracle
4 · Pattern before code
one lookup in Part 23
rediscovering known skeletons under time pressure
5 · Dry run before submit
2 minutes on paper
wrong answer on hidden tests, after the timer
6 · Complexity before accept
one arithmetic check
time 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 SolutionPython
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 SolutionPython
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
Complexity
Reads as
Where it shows up
n = 10^5 → steps
O(1)
constant
dict / set lookup, arithmetic, len()
1
O(log n)
halving
binary search, n //= 2 loops
17
O(n)
linear
one pass over the input
100,000
O(n log n)
sort + scan
sorted(), merging sorted lists
1,700,000
O(n²)
nested loops
checking every pair
10,000,000,000 ⚠️
O(2^n)
all subsets
exhaustive 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 loopsn ≤ 10^5 in the constraintswill this TLE?dict/set = O(1) lookup
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 valuein-place = write pointernever mutate while iteratingalias vs copy: nums[:]
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 oncetwo-pointer palindromechar counts in a dictenumerate for index + char
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 neededseen so farcomplement = target - xcount first, then scan
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 inputin-place overwritepalindromeone pass, O(1) space
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
binary-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
sortedsmallest value that worksminimize the maximumanswer stays inside [lo, hi]
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 servedBFS frontierround-robin turnsdeque, never list.pop(0)
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 caseanswer = f(left, right)BST: left < node < right, subtree-widefreeze queue size per level
yes — 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 connectionsgrid = graph with 4 directionsshortest → BFSexplore all → DFSno visited → infinite loop
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 countsort by deadline / endsafe local optimumalways hunt a counterexample
a.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 rangessort by start (or end)sweep once after sortingcheck the endpoint convention
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
Structure
Access
Search
Insert / delete
Note
list / array
O(1) by index
O(n) unsorted · O(log n) sorted
O(1) at end · O(n) front / middle
the default container
dict
—
O(1) avg by key
O(1) avg
keys → values
set
—
O(1) avg
O(1) avg
membership 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 list
O(n) walk
O(n)
O(1) rewire at a node
pointer-based
BST
O(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 SolutionPython
# 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 SolutionPython
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.
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
Stage
What to learn
Why now
Needs first
S0
Python fluency — functions, dicts, sets, classes
every solution is written in it; syntax must cost you nothing
—
S1
Complexity (Part 1)
a cost lens is required before you can judge any idea you will ever have
S0
S2
Arrays (Part 3)
the simplest data shape carries the richest habits: traverse, one-pass running values, in-place edits
S1
S3
Strings (Part 4)
arrays with a read-only rule — teaches build-list-join discipline
S2
S4
Hashing (Part 5)
adds O(1) lookup to everything you already do; kills whole families of nested loops
S2
S5
Two Pointers (Part 6)
exploits sorted input and in-place rewrites — your first "structure, not speed" tool
S2
S6
Sliding Window (Part 7)
two pointers that maintain a live segment; generalises the pair idea
S5
S7
Prefix Sum (Part 8)
window sums generalised to any range; pairs with hashing for sum-equals-k
S2, S4
S8
Sorting (Part 9)
makes hidden structure visible — the enabler for everything below
S2
S9
Binary Search (Part 10)
needs the sorted / monotonic lens you just built in S8
S8
S10
Stack + Queue (Parts 11–12)
first disciplined structures; the queue is also BFS's engine later
S2
S11
Linked List (Part 13)
pointer rewiring — a genuinely new way to think about data
S5, S10
S12
Recursion (Part 14)
shrinking calls; the foundation under trees, graphs, and backtracking
S1
S13
Backtracking (Part 15)
recursion that walks a decision tree with an undo button
S12
S14
Trees (Part 16)
recursion made visible — a call tree you can see and touch
S12, S13
S15
Heap (Part 17)
a tree stored in an array; handles all "top-k / kth" work
S2, S14
S16
Graphs (Part 18)
recursion + queue working together — the everything-pattern
S10, S12
S17
Greedy (Part 19)
sort + local choice + proof habits; needs sorting maturity from S8
S8
S18
Intervals (Part 20)
greedy applied to ranges — one recipe, many problems
S8, S17
S19
Bit Manipulation (Part 21)
cheap O(1) tools and the XOR cancellation trick — a finale, not a foundation
S1
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.
Topic
Solve
You are ready to move on when…
Python (S0)
0
you can rebuild 3 small programs from memory — syntax is no longer a tax
x & 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:
Pass
When
What you do
Time
Pass 1
1 day after learning the pattern
re-solve the 2 problems you struggled with most, from a blank file; glance at the cheat sheet only afterwards to correct
15–20 min
Pass 2
1 week later
re-solve 1 problem cold + write the topic's template from memory before typing anything else
20 min
Pass 3
1 month later
recognition only: read 5 statements and name the pattern + complexity for each, Part 31 style — no code required
30 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 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 group
Problems
Realistic time (1 focused hour / day)
S0–S1 · Python + complexity
5
1 week
S2–S4 · Arrays, strings, hashing
24
3 weeks
S5–S7 · Two pointers, window, prefix sum
17
2.5 weeks
S8–S9 · Sorting + binary search
14
2 weeks
S10–S11 · Stack, queue, linked list
18
2 weeks
S12–S14 · Recursion, backtracking, trees
21
3 weeks
S15–S16 · Heap + graphs
13
2 weeks
S17–S19 · Greedy, intervals, bit manipulation
15
2 weeks
Total
127
~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.
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 SolutionPython
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).