learn
Weeks 33–35 · About 40 hours

Choosing the right approach

The previous stages gave you tools. This one is about recognising which tool an unfamiliar problem calls for.

Weeks 16 to 32 built a toolbox. This stage is about diagnosis — looking at a problem you have never seen and knowing, within a few minutes, what kind of problem it is.

This is the part that actually transfers to engineering work. Real problems do not arrive labelled. The skill of recognising “this is a graph problem wearing a disguise” is what separates knowing the from being able to use them.

NeetCode 150 · complete category

Two Pointers practice lab

Use two coordinated positions to eliminate impossible choices without repeatedly scanning the same range.

5 problems
Pattern first

Two pointers: discard impossible pairs

1 + 12 = 13 — if too small move L; if too large move R.

Naive

All-pairs testing repeats comparisons sorted order has already made impossible.

Why this works

If a sorted pair is too small, only a larger left value helps; if too large, only a smaller right value helps.

Problem 1 of 5

Valid Palindrome

Solve on NeetCode ↗

Check whether A man, a plan, a canal: Panama is a palindrome.

Picture itTwo proofreaders start at opposite covers, ignoring punctuation and comparing the next real letters.

Conceptual blueprint

Move inward from both ends, skip non-alphanumeric characters, compare lowercase letters, and stop on a mismatch.

Animated dry-runFrame 1
1 / 6
0A
1m
2a
3n
4a
5P
6a
7n
8a
9m
10a
L at index 0, R at index 10

Lowercased A on the left equals the final a; punctuation between displayed letters is skipped by lines 4–5.

paira = a
1def is_palindrome(text):
2 left, right = 0, len(text) - 1
3 while left < right:
4 while left < right and not text[left].isalnum(): left += 1
5 while left < right and not text[right].isalnum(): right -= 1
6 if text[left].lower() != text[right].lower(): return False
7 left += 1
8 right -= 1
9 return True
Check yourself before reading on
Cost it first

What does the in-place two-pointer check trade compared with cleaning and reversing?

Find the bug

Which missing guard can read outside the string?

def is_palindrome(text):
    left, right = 0, len(text) - 1
    while left < right:
        while not text[left].isalnum(): left += 1
        while not text[right].isalnum(): right -= 1
        if text[left].lower() != text[right].lower(): return False
        left += 1
        right -= 1
    return True
Predict the next step

After the a pair matches, which significant pair is compared next?

Fill in the blanks

Recall each hidden line before revealing it. 3 left.

1def is_palindrome(text):
2 left, right = 0, len(text) - 1
3 while left < right:
4
5
6
7 left += 1
8 right -= 1
9 return True
Why the naive approach falls short

Build a cleaned lowercase copy and reverse it, which is simple but allocates memory proportional to the text.

Why the optimized approach works

Every significant character has exactly one mirrored partner, so inward pointers verify pairs without copying the string.

Time

O(n) — each pointer crosses the text once.

Space

O(1) — comparisons happen in place.

NeetCode 150 · complete category

Sliding Window practice lab

When the answer is about a contiguous run, move one boundary forward and repair the other instead of rebuilding the run from scratch.

2 problems
Pattern first

A window that only ever moves forward

Naive

Re-examining every substring or subarray from its own start repeats work the previous window already did.

Why this works

Both boundaries only advance, so each element enters the window once and leaves once — 2n moves, not n² of them.

Problem 1 of 2

Longest Substring Without Repeating Characters

Solve on NeetCode ↗

Find the longest repeat-free run in "abcabcbb".

Picture itA tour guide keeps a rope around a group; when a duplicate face appears, the rope's back end jumps forward past the earlier copy.

Conceptual blueprint

Remember where each character was last seen; when one repeats inside the window, move the start just past its previous position.

Animated dry-runFrame 1
1 / 8
0a
1b
2c
3a
4b
5c
6b
7b
start at index 0, i at index 0

The window is just "a"; record that a was last seen at index 0.

best1
Lookup structure
a → 0
1def length_of_longest(s):
2 last_seen = {}
3 start = best = 0
4 for i, ch in enumerate(s):
5 if ch in last_seen and last_seen[ch] >= start:
6 start = last_seen[ch] + 1
7 last_seen[ch] = i
8 best = max(best, i - start + 1)
9 return best
Check yourself before reading on
Cost it first

What does storing last-seen positions buy over restarting the scan at each index?

Find the bug

On "abba" this reports 3. Which character is it wrongly counting?

def length_of_longest(s):
    last_seen = {}
    start = best = 0
    for i, ch in enumerate(s):
        if ch in last_seen:
            start = last_seen[ch] + 1
        last_seen[ch] = i
        best = max(best, i - start + 1)
    return best
Predict the next step

The a at index 3 has just moved start to 1. When the b at index 4 is read next, where does start go?

Fill in the blanks

Recall each hidden line before revealing it. 3 left.

1def length_of_longest(s):
2 last_seen = {}
3 start = best = 0
4 for i, ch in enumerate(s):
5
6
7 last_seen[ch] = i
8
9 return best
Why the naive approach falls short

Start a fresh scan at every index and stop at the first repeat, re-reading almost the whole string each time.

Why the optimized approach works

The start boundary never moves backwards, so every character is entered and left exactly once.

Time

O(n) — each index is read once and start only moves forward.

Space

O(k) — one entry per distinct character.

NeetCode 150 · complete category

1-D Dynamic Programming practice lab

When the answer at position i depends only on a couple of earlier answers, carry those answers forward instead of recomputing them.

2 problems
Pattern first

A recurrence you can hold in two variables

Naive

Plain recursion rebuilds the same subproblem an exponential number of times.

Why this works

Each position's answer is a fixed function of the ones just before it, so a single forward pass carrying the last two values is enough.

Problem 1 of 2

Climbing Stairs

Solve on NeetCode ↗

How many ways are there to climb 5 steps taking 1 or 2 at a time?

Picture itEveryone standing on step 5 arrived from step 4 or step 3, so step 5's count is those two crowds added together.

Conceptual blueprint

Carry the counts for the previous two steps and slide them forward once per step.

Animated dry-runFrame 1
1 / 6
01
11
2?
3?
4?
5?
step at index 1

There is one way to stand at the bottom and one way to reach step 1; those two seeds are the whole base case.

prev (step 0)1current (step 1)1
1def climb_stairs(n):
2 prev, current = 1, 1
3 for _ in range(n - 1):
4 prev, current = current, prev + current
5 return current
Check yourself before reading on
Cost it first

Naive recursion here is O(2ⁿ). What does the loop trade to reach O(n)?

Find the bug

Why does n = 5 return 16 instead of 8?

def climb_stairs(n):
    prev, current = 1, 1
    for _ in range(n - 1):
        current = prev + current
        prev = current
    return current
Predict the next step

Step 3 holds 3 and step 2 holds 2. What is step 4?

Fill in the blanks

Recall each hidden line before revealing it. 2 left.

1def climb_stairs(n):
2
3 for _ in range(n - 1):
4
5 return current
Why the naive approach falls short

Recurse on n-1 and n-2, rebuilding the same subtree of calls again and again.

Why the optimized approach works

The last move was either a 1 or a 2, and those two cases cannot overlap, so ways(n) = ways(n-1) + ways(n-2).

Time

O(n) — one pass, one addition per step.

Space

O(1) — two integers, no table.

NeetCode 150 · complete category

Backtracking practice lab

Build a candidate one choice at a time, and undo the last choice when its branch is finished.

2 problems
Pattern first

Choose, explore, un-choose

Naive

Building every combination independently duplicates the shared prefixes over and over.

Why this works

One mutable path plus a matching undo visits every branch while only ever holding a single partial answer in memory.

Problem 1 of 2

Subsets

Solve on NeetCode ↗

List all 8 subsets of [1, 2, 3].

Picture itWalking a decision tree with a rucksack: you put an item in, walk deeper, then take it out again on the way back.

Conceptual blueprint

Record the current path, then for each remaining index add it, recurse from the next index, and remove it.

Animated dry-runFrame 1
1 / 8
01
12
23

The empty path is recorded first — the empty set is a subset too.

path[]start0
Lookup structure
result → [[]]
1def subsets(nums):
2 result = []
3 path = []
4 def explore(start):
5 result.append(path[:])
6 for i in range(start, len(nums)):
7 path.append(nums[i])
8 explore(i + 1)
9 path.pop()
10 explore(0)
11 return result
Check yourself before reading on
Cost it first

Why is `path[:]` a copy rather than just `path`?

Find the bug

The pop is missing. What does the path look like by the end?

def subsets(nums):
    result = []
    path = []
    def explore(start):
        result.append(path[:])
        for i in range(start, len(nums)):
            path.append(nums[i])
            explore(i + 1)
    explore(0)
    return result
Predict the next step

3 has just been popped and the path is [1, 2]. What happens next?

Fill in the blanks

Recall each hidden line before revealing it. 3 left.

1def subsets(nums):
2 result = []
3 path = []
4 def explore(start):
5 result.append(path[:])
6 for i in range(start, len(nums)):
7
8
9
10 explore(0)
11 return result
Why the naive approach falls short

Generate strings of choices and re-parse them into lists afterwards.

Why the optimized approach works

Every subset is a distinct path through the choice tree, and recursing from i + 1 fixes the order so no subset appears twice.

Time

O(n · 2ⁿ) — 2ⁿ subsets, each copied once.

Space

O(n) working space beyond the output — one path and one frame per level.

NeetCode 150 · complete category

2-D Dynamic Programming practice lab

When a subproblem is identified by two indices rather than one, the table becomes a grid — and often only its last row is needed.

2 problems
Pattern first

Two moving parts, one table

Naive

Recursing on both indices recomputes the same (i, j) pair enormously often.

Why this works

Each cell depends only on neighbours already computed, so filling the grid in order answers every subproblem exactly once.

Problem 1 of 2

Unique Paths

Solve on NeetCode ↗

How many right-and-down paths cross a 3 × 3 grid?

Picture itWater poured on the top-left corner, where each cell pools everything arriving from above and from the left.

Conceptual blueprint

Every cell's count is the cell above plus the cell to its left; the first row and first column are all 1.

Animated dry-runFrame 1
1 / 6
01
11
21

One row of cells is reused for each row of the grid. There is exactly one way to reach any cell in the top row: keep moving right.

meaningthe grid's top row
Lookup structure
row → [1, 1, 1]
1def unique_paths(rows, cols):
2 row = [1] * cols
3 for _ in range(rows - 1):
4 for c in range(1, cols):
5 row[c] += row[c - 1]
6 return row[-1]
Check yourself before reading on
Cost it first

Why is one row enough instead of the full rows × cols table?

Find the bug

The inner loop starts at 0. What breaks?

def unique_paths(rows, cols):
    row = [1] * cols
    for _ in range(rows - 1):
        for c in range(cols):
            row[c] += row[c - 1]
    return row[-1]
Predict the next step

The row reads [1, 2, 3] after the first pass. What is the middle cell after the next pass?

Fill in the blanks

Recall each hidden line before revealing it. 3 left.

1def unique_paths(rows, cols):
2
3 for _ in range(rows - 1):
4
5
6 return row[-1]
Why the naive approach falls short

Enumerate every sequence of moves, which is exponential in the grid size.

Why the optimized approach works

A path reaching a cell arrived from exactly one of two neighbours, and those two sets of paths never overlap.

Time

O(rows × cols) — each cell is written once.

Space

O(cols) — only one row is kept, not the whole grid.

Brute force

every candidate the rules allow and keeps the ones that work.

Reach for it whenUse brute force instead of a clever algorithm when the candidate space is small or as a correctness oracle; do not use it when combinations grow beyond the available time.

Write the obvious, exhaustive version first. It is quick to write and almost certainly correct, and that makes it your reference: when you write the fast version, you can run both on random inputs and check they agree. Optimised code is where bugs hide, and a known-correct baseline is the cheapest testing you will ever do.

It also tells you whether optimising is needed at all. If the input is never more than fifty items, the brute-force version may simply be the right answer, and time spent making it clever is time wasted.

Syntax you can use
for left in range(len(nums)): for right in range(left + 1, len(nums)): if nums[left] + nums[right] == target: return left, right
Checks every pair, a clear first solution for two-sum.
from itertools import combinations
Imports a generator for choosing groups without writing nested loops.
for pair in combinations(nums, 2):
Visits every two-item combination.
best = min(candidates, key=score)
Chooses the best answer after trying every candidate.
workdata →nn log nn^2
Notice how quadratic work separates from a linear scan; brute force is a baseline whose feasible input size must be measured.
Several ways to see it
Why it existsThe obvious complete search is the first correctness oracle

Trying every candidate makes few structural assumptions and is therefore hard to misunderstand. Even when too slow for production, it supplies expected answers for small random cases used to test an optimized solution.

What it costsThe exponent decides when hardware stops helping

Quadratic enumeration may be fine for hundreds of items, while `2^n` can become impossible around a few dozen. Estimate candidate count times work per candidate before optimizing inner syntax.

Against the alternativePruning keeps completeness while avoiding doomed branches

A pruned search is still exhaustive over candidates that could succeed. Greedy or heuristic methods may skip valid candidates entirely, gaining speed by giving up either certainty or generality.

In the wild
  • Hashcat — can enumerate password candidates from masks and test each candidate against a target hash.
  • pytest parametrize — runs the same test body across an explicitly enumerated set of input cases.
Commonly gets misread as
  • "Brute force is always bad engineering." Correction: for small bounds it is simple, auditable, and often fastest enough.
  • "Faster hardware fixes exponential growth." Correction: one extra input dimension can multiply the search beyond reach.

Divide and conquer

splits one large job into similar smaller jobs, solves them, then combines their answers.

Reach for it whenUse it instead of one shared-state loop when subproblems are independent and combine cleanly; avoid it when splitting duplicates most of the work.

Merge sort is the canonical example: split in half, sort each half, merge. Quicksort is another. The shape recurs constantly once you look for it.

The Master Theorem is a formula for working out the Big-O of a divide-and-conquer algorithm from three facts: how many pieces you split into, how much smaller each piece is, and how much work the combining takes. It is worth being able to apply, and not worth memorising the proof of.

Syntax you can use
mid = len(items) // 2
Finds a midpoint for splitting a problem in two.
left = solve(items[:mid]) right = solve(items[mid:])
Solves two smaller halves recursively.
return combine(left, right)
Combines the two solved halves into one answer.
if len(items) <= 1: return items
Stops when the problem is already small enough to answer directly.
8442222
Notice that balanced splitting creates only three levels for eight items; each level still covers all eight during merging.
Several ways to see it
Its shapeThe recursion tree reveals both depth and duplicated work

Balanced halves create about log n levels, while the total work across each level determines the final cost. Merge sort does O(n) combining per level and therefore totals O(n log n).

What the machine doesThe combine step is where separate answers become useful

Splitting alone merely creates smaller inputs. The algorithm succeeds because sorted halves can be merged linearly, or independently solved regions can be reconciled without reopening every internal decision.

How it breaksUneven splits can erase the logarithmic depth

Quicksort with a consistently extreme pivot produces subproblems of sizes zero and n minus one. The tree becomes a chain and comparison work becomes quadratic.

In the wild
  • Hadoop MapReduce — runs map work across input splits and combines grouped results in reduce tasks.
  • Python multiprocessing Pool — distributes independent input chunks to worker processes and gathers their results.
Commonly gets misread as
  • "Splitting makes the total work disappear." Correction: partitioning and combining still cost time.
  • "Every recursive algorithm is divide and conquer." Correction: the subproblems must be meaningfully separate and recombined.

Greedy algorithms

A commits to the best-looking move now without reopening earlier choices.

Reach for it whenUse greedy instead of only when an exchange argument or invariant proves local choices lead to a global optimum; otherwise test for counterexamples.

Giving change is the intuitive example: to make 87p, take the largest coin that fits and repeat. With normal currency this always gives the fewest coins. With a made-up currency it does not, and that is exactly the danger.

A greedy approach is correct only when the problem has two properties. The greedy choice property: taking the locally best option never rules out the overall best answer. And optimal substructure: the best answer contains the best answers to its sub-parts. If you cannot argue that both hold, do not trust a greedy solution — test it against brute force on small random inputs.

Syntax you can use
intervals.sort(key=lambda interval: interval[1])
Orders intervals by earliest finishing time for interval scheduling.
if start >= last_end: chosen.append((start, end)) last_end = end
Takes the next compatible interval after choosing the earliest finish.
for coin in sorted(coins, reverse=True):
Considers the largest available coin first.
take = min(remaining // coin, available)
Takes as much of a locally best choice as the constraints allow.
Several ways to see it
What the machine doesA greedy step permanently narrows the future

At each state the algorithm takes the locally best admissible move and never revisits it. The proof burden is showing that some optimal solution can always be rearranged to include that move.

Against the alternativeDynamic programming pays to preserve competing futures

Greedy keeps one promising choice and is usually simpler and lighter. Dynamic programming retains values for multiple states when a locally worse choice may enable a better total later.

How it breaksCoin systems expose how plausible rules fail

With coins 1, 3, and 4, taking the largest coin first makes 6 as 4 + 1 + 1. The optimum is 3 + 3, so a familiar greedy rule needs a proof tied to the input structure, not confidence from examples.

In the wild
  • gzip — builds Huffman codes by repeatedly combining the two least-frequent nodes.
  • NetworkX Kruskal MST — takes the cheapest safe edge next while skipping edges that would make a cycle.
Commonly gets misread as
  • "Choosing the current best usually gives the final best." Correction: it does only for problems with the right structure.
  • "Greedy means no ." Correction: heaps and union-find often make the next safe choice efficient.

Backtracking

builds one possible answer, abandons it as soon as it cannot work, and returns to the last choice point.

Reach for it whenUse backtracking instead of flat brute force when partial choices can be rejected early; use dynamic programming when many branches repeat the same subproblem.

Think of it likeSolving a maze. Walk down a corridor, and reverse out the instant you hit a dead end rather than continuing to stare at the wall.

It is exhaustive search with pruning. You still explore possibilities, but you cut off entire branches as soon as they are provably hopeless, which can remove astronomically many combinations without ever checking them.

Solving Sudoku is the standard example: place a digit, check whether the grid is still valid, and if not, undo it immediately rather than filling the remaining eighty squares first.

Syntax you can use
def search(path): if complete(path): answers.append(path.copy()) return
Ends one branch when it forms a complete answer.
for choice in choices(path): path.append(choice) search(path) path.pop()
The choose, explore, un-choose shape for exhaustive search.
if not valid(path): return
Prunes a branch as soon as it cannot lead to a valid answer.
answers.append(path.copy())
Saves the current answer without sharing the mutable path.Appending path itself saves repeated references to one list, which later pops will empty.
start01000110
Notice that rejecting the 10 prefix removes both completions below it; pruning saves entire subtrees, not single guesses.
Several ways to see it
Its shapeThe search space is a tree of partial decisions

Each level commits one more choice and each child represents an alternative. A leaf is either a complete solution or a dead end; pruning cuts a whole subtree before enumerating its leaves.

What the machine doesChoose, recurse, and undo must be symmetric

The algorithm mutates a shared partial solution before descending and reverses exactly that mutation afterward. A missing undo leaks one branch's state into its siblings and produces failures far from the faulty line.

What it costsA strong constraint is an exponential optimization

Rejecting one impossible prefix can eliminate millions of complete candidates beneath it. Check cheap, selective constraints early and choose the next variable with the fewest legal options when possible.

In the wild
  • CPython re — uses a backtracking regex engine that can revisit earlier match choices when a later token fails.
  • OR-Tools CP-SAT — searches combinatorial assignments while propagating constraints and backtracking from conflicts.
Commonly gets misread as
  • "Backtracking is just recursion." Correction: recursion carries the search, but pruning invalid partial choices is the point.
  • "It makes exponential search polynomial." Correction: pruning helps typical cases but worst-case growth can remain exponential.

Dynamic programming

Dynamic programming solves each repeated smaller problem once and reuses its answer wherever it appears again.

Reach for it whenUse it instead of backtracking when branches revisit the same state and the full answer is built from optimal smaller answers; avoid it when states do not repeat.

Think of it likeWorking out a long sum and writing intermediate totals in the margin, rather than re-adding the same column every time you need it.

Go back to the recursive Fibonacci you wrote in week 15. To compute Fibonacci of 40 it computes Fibonacci of 38 twice, Fibonacci of 37 three times, and so on — hundreds of millions of calls for an answer involving forty numbers. Add a dictionary that remembers each answer the first time and it returns instantly. That is the entire idea.

There are two ways to write it. is top-down: keep the natural recursion and cache the results. In Python, adding @functools.cache above a function does this for you in one line, which makes it a very cheap experiment. Tabulation is bottom-up: work out the smallest cases first and build up in a table, avoiding recursion entirely.

Syntax you can use
from functools import cache
Imports memoisation for a top-down dynamic programme.
@cache def ways(n): if n <= 1: return 1 return ways(n - 1) + ways(n - 2)
Turns repeated recursive subproblems into one cached result each.
dp = [0] * (n + 1)
Allocates a table with one answer slot per subproblem.
for i in range(2, n + 1): dp[i] = dp[i - 1] + dp[i - 2]
Builds answers bottom-up after their dependencies exist.
dp = [[0] * cols for _ in range(rows)]
Creates independent rows for a two-dimensional table.[[0] * cols] * rows repeats the same row object, so one write appears in every row.
F(5)F(4)F(3)F(3)F(2)
Notice that both branches request F(3); caching turns the second exponential subtree into one lookup.
Several ways to see it
Why it existsRepeated subproblems turned recursion into wasted work

Naive recursive Fibonacci recomputes the same smaller values through many branches. Dynamic programming names each distinct state and solves it once, replacing an exponential call tree with a table or cache.

Against the alternativeMemoization follows demand; tabulation controls order

A memoized recursion calculates only states reached from the query and often mirrors the recurrence clearly. Bottom-up tabulation avoids call overhead and makes dependencies and memory reuse explicit, but may fill states the query never needs.

How it breaksThe wrong state loses information the future needs

If two partial solutions share a table cell but have different consequences for later choices, the recurrence cannot be correct. Define state as the smallest summary that makes every future decision independent of the path used to reach it.

In the wild
  • GNU diff — uses Myers' sequence-difference algorithm to reuse progress across edit paths while finding a short edit script.
  • SQLite spellfix1 — computes edit distance to rank vocabulary words against a misspelled query.
Commonly gets misread as
  • "Dynamic programming means writing a table." Correction: the table or cache only stores answers to a repeated-state recurrence.
  • "Memoization makes every recursion fast." Correction: it helps only when different calls reach the same state.

Two pointers, sliding windows and prefix sums

and windows keep a few moving boundaries so one pass can reuse what the previous position already knew.

Reach for it whenUse them instead of nested scans on ordered sequences or contiguous ranges when adding and removing boundary items updates the answer; use prefix sums for many fixed range totals.

keeps track of two positions at once. On sorted data, to find a pair adding to a target, start one pointer at each end: if the sum is too big move the right one in, if too small move the left one out. One pass instead of checking every pair.

A looks at a stretch of the list and moves it along, adding the item entering and removing the item leaving, rather than re-examining the whole stretch each time. Any question about the best run of k consecutive items is this.

Syntax you can use
left, right = 0, len(nums) - 1
Starts two pointers at opposite ends of a sorted list.
while left < right: total = nums[left] + nums[right] if total < target: left += 1 else: right -= 1
Moves the useful pointer after comparing a pair sum with the target.
left = 0 for right, value in enumerate(nums):
Grows a sliding window one item at a time.
while window_sum > limit: window_sum -= nums[left] left += 1
Shrinks a window until it satisfies its constraint.
25143
Notice that moving the window right reuses the three middle values; only one value leaves and one enters.
Several ways to see it
Its shapeAll three methods reuse overlap between neighbouring questions

Pointers mark changing boundaries, a window carries the aggregate between those boundaries, and a prefix stores every aggregate from the start. Each avoids recounting the unchanged middle of nearby ranges.

Against the alternativeWindows move online; prefix sums answer later

A sliding window is ideal when one pass can maintain exactly the current range and its validity. Prefix sums spend O(n) memory up front so arbitrary static range sums become constant-time queries.

How it breaksNegative values can destroy monotonic window logic

Shrinking a sum-too-large window works for non-negative values because removing from the left cannot increase the sum. With negatives, expansion and contraction no longer move the condition predictably, so a prefix-sum map may be required.

In the wild
  • TCP — uses a sliding receive window to advertise which byte range the receiver can currently accept.
  • Transformer context windows — limit attention and retained tokens to a bounded span when processing long sequences.
Commonly gets misread as
  • "A sliding window must have fixed length." Correction: its ends can move independently to maintain a condition.
  • "Two pointers means two nested loops." Correction: each pointer usually advances only forward, keeping total work linear.

What it costsEach typically reduces an O(n²) approach to O(n).

Bit manipulation

Bit manipulation treats an integer as a row of on-off switches that can be tested or changed together.

Reach for it whenUse bit masks instead of many booleans when flags are compact and combinations matter; use named fields when readability and future expansion matter more.

A useful first one: exclusive-or has the property that a number combined with itself cancels to zero. So combining every item in a list where everything appears twice except one leaves exactly that one, using no extra memory at all.

Bitmasks let a single number carry many yes/no flags at once — one bit per permission, per feature toggle, per option. Compact and fast to check.

Syntax you can use
n & 1
Reads the last bit: 1 for odd n, 0 for even n.
n >> 1
Shifts bits right by one, like floor-dividing a non-negative integer by two.
n | (1 << bit)
Sets a numbered bit to 1.
n & ~(1 << bit)
Clears a numbered bit to 0.
n ^ (1 << bit)
Flips a numbered bit.
n & (n - 1)
Clears the lowest set bit, useful for counting set bits.
1read0write1exec0admin
Notice that one integer stores four independent yes-or-no flags; AND can inspect any chosen position without unpacking the others.
Several ways to see it
Its shapeA bitmask is a row of independent switches

Bit position i can encode whether option i is present. OR turns switches on, AND tests or filters them, XOR toggles differences, and a shift moves the whole pattern across positions.

What the machine doesTwo's complement makes negative bits conceptually unbounded

Python integers do not have a fixed visible word size, so negative values behave as if sign bits continue forever. Mask with `(1 << width) - 1` when modelling a 32-bit register or protocol field.

Against the alternativeA set is clearer until representation is the problem

For ordinary membership logic, Python's set names the intent and handles arbitrary elements. A bitmask earns its density when the universe is small and fixed, or when hardware flags, permissions, subsets, or binary formats are the subject.

In the wild
  • Unix file permissions — stores read, write, and execute flags as bits combined into a mode value.
  • PostgreSQL bitmap scans — combines bitmap index results before visiting the matching heap pages.
Commonly gets misread as
  • "Bit tricks are automatically faster." Correction: clarity and the compiler often matter more than replacing ordinary arithmetic.
  • "A bit mask can store any number of flags." Correction: it is limited by the chosen integer width unless expanded.

Reading about this stage is not the same as finishing it. Type every one of these from a blank file rather than copying — the writing is the part that teaches.

The dynamic programming ladder

Work through Fibonacci, climbing stairs, coin change, 0/1 knapsack, longest common subsequence, longest increasing subsequence and edit distance — in that order. Write each one both memoised and tabulated. This is the single biggest time investment of the stage and it is correctly placed.

Write a tiny diff tool

Use longest common subsequence to compare two text files and print which lines were added and removed. You will have written a simplified version of what git shows you every day.

Break a greedy algorithm on purpose

Write greedy coin change, then invent a currency where it gives the wrong answer, and confirm it by comparing against a brute-force solution. Experiencing greedy failing is what stops you trusting it blindly.

Sudoku solver

Backtracking, end to end. Choose, explore, un-choose. Then add better pruning — always filling the most constrained square next — and measure how much faster it gets.

Exit checkGiven a problem you have never seen, you can name the likely approach within five minutes and justify why. Around 300 problems in total — though see the note below on volume.

If you cannot do this, repeat the stage rather than moving on. Nothing later gets easier by skipping it.

Worth knowing before you start

  • Dynamic programming is where people quit. It is normal for it to take several attempts before it clicks. Work the ladder in order rather than jumping to hard problems.
  • The 300-problem figure is calibrated for interview preparation. For general engineering skill, around 150 well-understood problems beat 300 rushed ones. Depth over volume.

This stage corresponds to DSA Phase 5 — Problem-Solving Paradigms in the source roadmap.