Intermediate Examples
Examples 25-52 extend the beginner tier into amortized analysis (the doubling dynamic array and the accounting method), quicksort's practical fixes (a random pivot, then quickselect), divide-and-conquer geometry (closest pair of points), Fenwick and segment trees, optimized union-find (union-by-rank, path compression) and connected components, topological sort (Kahn's algorithm and DFS finish order) with cycle detection, Dijkstra (including the unreachable-node case) and Bellman-Ford (including negative-cycle detection), minimum spanning trees (Kruskal and Prim), the greedy paradigm (a correct case and a failing one), and the first wave of dynamic programming (Fibonacci, climbing stairs, coin change, edit distance, longest common subsequence, 0/1 knapsack), closing with binary search boundaries. Every example runs and verifies exactly like the beginner tier -- python3 example.py for inline output, pytest for the colocated test_example.py.
Example 25: A Doubling Dynamic Array's Amortized O(1) Append
ex-25 · exercises co-02
list.append is amortized O(1) because a resize doubles capacity: rare, expensive O(n) copies get outweighed by many cheap O(1) appends in between. This example counts total element copies across many appends and confirms the average cost per append stays bounded.
learning/code/ex-25-amortized-dynamic-array/example.py
"""Example 25: A Doubling Dynamic Array -- Amortized O(1) Append, Counted."""
# list.append is amortized O(1) because resizes DOUBLE capacity (co-02):
# rare, expensive O(n) copies are outweighed by many cheap O(1) appends in
# between. This example implements that doubling explicitly and COUNTS copies.
class DynamicArray: # => a from-scratch array that grows by doubling, like CPython's
def __init__(self) -> None:
self.capacity: int = 1 # => starts with room for exactly 1 element
self.size: int = 0 # => how many elements are actually stored so far
self.data: list[int | None] = [None] * self.capacity # => the backing storage
self.total_copies: int = 0 # => running count of element copies during resizes
def append(self, value: int) -> None: # => amortized O(1) per call
if self.size == self.capacity: # => backing storage is full -- must grow
self._resize(self.capacity * 2) # => DOUBLING is what makes this amortized
self.data[self.size] = value # => O(1): writes into the next free slot
self.size += 1 # => one more element stored
def _resize(self, new_capacity: int) -> None: # => O(n): the rare, expensive step
new_data: list[int | None] = [None] * new_capacity # => a fresh, larger array
for i in range(self.size): # => copies every EXISTING element over
new_data[i] = self.data[i] # => one copy per existing element
self.total_copies += 1 # => tallies this copy for the amortized-cost check
self.data = new_data # => the array now points at the larger backing storage
self.capacity = new_capacity # => capacity reflects the new, larger size
arr = DynamicArray() # => starts empty, capacity 1
n = 1000 # => how many appends to perform
for i in range(n): # => n appends -- MOST are O(1), a few trigger an O(n) resize
arr.append(i) # => amortized O(1) each
average_copies_per_append = arr.total_copies / n # => the amortized cost per append
print(arr.size) # => Output: 1000
print(arr.total_copies) # => Output: 1023 -- sum of 1+2+4+...+512, just over n
print(f"{average_copies_per_append:.3f}") # => Output: 1.023
assert arr.size == n # => confirms every append landed correctly
assert (
average_copies_per_append < 2.0
) # => confirms the amortized cost stays BOUNDED by a small constant, not O(n)
print("ex-25 OK") # => Output: ex-25 OKRun: python3 example.py
Output:
1000
1023
1.023
ex-25 OKlearning/code/ex-25-amortized-dynamic-array/test_example.py
"""Example 25: pytest verification for the Doubling Dynamic Array."""
from example import DynamicArray
def test_all_appended_values_are_stored_in_order() -> None:
arr = DynamicArray()
for v in [10, 20, 30]:
arr.append(v)
assert arr.size == 3
assert [arr.data[i] for i in range(arr.size)] == [10, 20, 30]
def test_amortized_copy_count_stays_bounded_as_n_grows() -> None:
small = DynamicArray()
for i in range(100):
small.append(i)
large = DynamicArray()
for i in range(10_000):
large.append(i)
small_ratio = small.total_copies / 100
large_ratio = large.total_copies / 10_000
assert large_ratio < 2.0 # => a 100x bigger n does NOT proportionally grow copies
assert small_ratio < 2.0
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Amortized analysis looks at the TOTAL cost across a whole sequence of operations, divided by the number of operations -- not the worst-case cost of any single operation, which is what makes an occasional O(n) resize compatible with an 'amortized O(1) append' claim.
Why it matters: This is the single most important amortized-analysis example in the topic, because list.append is used constantly and its 'O(1)' claim is not literally true for every call -- it is true on average. Examples 26 and 77 revisit this same structure with two more rigorous proof techniques (accounting and potential methods) once the intuition here is established.
Example 26: The Accounting Method
ex-26 · exercises co-02
The accounting method charges each operation a fixed amortized cost, banking the surplus over what the operation actually spends, then cashes that surplus in to pay for expensive operations later. This example charges every append 3 credits and confirms the running credit balance never dips below zero.
learning/code/ex-26-amortized-accounting-method/example.py
"""Example 26: The Accounting Method -- Credits That Never Go Negative."""
# The accounting method (co-02) charges each operation a fixed AMORTIZED cost
# (here, 3 credits per append), banking the surplus over what the operation
# actually spends. A resize later "cashes in" that banked surplus to pay for
# copying every existing element -- the proof this works is that the credit
# balance never dips below zero, across the ENTIRE sequence of operations.
class AccountedArray: # => a doubling array instrumented to track its credit ledger
def __init__(self) -> None:
self.capacity: int = 1 # => starts with room for 1 element
self.size: int = 0 # => elements actually stored
self.credit_balance: int = 0 # => the running amortized-credit ledger
def append(self, value: int) -> None: # => charges 3, an amortized O(1) cost
actual_cost = 1 # => the baseline cost: writing one new element
if self.size == self.capacity: # => full -- a resize must happen first
actual_cost += self.size # => ALSO pays 1 per existing element it copies
self.capacity *= 2 # => doubles capacity, same as Example 25
self.credit_balance += 3 # => charges this append the fixed amortized rate
self.credit_balance -= actual_cost # => pays for whatever actually happened
assert (
self.credit_balance >= 0
) # => THE PROOF: banked credit always covers the real cost
self.size += 1 # => one more element now stored
arr = AccountedArray() # => starts empty
balances: list[int] = [] # => records the credit balance after every single append
for i in range(500): # => 500 appends -- several of them trigger a doubling resize
arr.append(i) # => charges 3, pays the real cost, asserts balance >= 0 internally
balances.append(arr.credit_balance) # => snapshots the balance for inspection
print(min(balances)) # => Output: 2 -- the balance dips low but never below zero
print(arr.credit_balance) # => Output: 489 -- the final leftover credit
print(arr.size) # => Output: 500
assert min(balances) >= 0 # => confirms the balance NEVER went negative, at any point
assert arr.size == 500 # => confirms every append actually landed
print("ex-26 OK") # => Output: ex-26 OKRun: python3 example.py
Output:
2
489
500
ex-26 OKlearning/code/ex-26-amortized-accounting-method/test_example.py
"""Example 26: pytest verification for the Accounting Method."""
from example import AccountedArray
def test_credit_balance_never_goes_negative_across_many_resizes() -> None:
arr = AccountedArray()
for i in range(2000): # => several doublings happen along the way
arr.append(i)
assert arr.credit_balance >= 0 # => re-checked after every single append
def test_final_size_matches_number_of_appends() -> None:
arr = AccountedArray()
for i in range(37): # => a deliberately non-power-of-two count
arr.append(i)
assert arr.size == 37
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: The accounting method PROVES an amortized bound by showing a credit balance never goes negative across an entire operation sequence -- if the balance stays nonnegative, the fixed charge per operation really was enough to cover every operation's actual cost, expensive ones included.
Why it matters: Unlike Example 25's aggregate method (total cost divided by operation count), the accounting method assigns a cost to EACH operation individually and proves the bookkeeping works out -- a technique that generalizes to situations (like Example 77's multi-pop stack) where different operation types have very different actual costs, not just occasional expensive resizes.
Example 27: Randomized-Pivot Quicksort
ex-27 · exercises co-08
Example 8's naive quicksort always picks the first element, so sorted input triggers its O(n^2) worst case. This example picks a random pivot on every call instead, and confirms already-sorted input no longer degrades the comparison count.
learning/code/ex-27-quicksort-random-pivot/example.py
"""Example 27: Randomized-Pivot Quicksort -- Sorted Input No Longer Degrades."""
# Example 8's naive quicksort always picks the FIRST element, so sorted input
# triggers its O(n^2) worst case. Picking a RANDOM pivot each time (co-08)
# makes that worst case astronomically unlikely -- expected O(n log n) even
# on already-sorted input, because the bad case no longer depends on the DATA.
import random
comparisons = 0 # => a global counter, reset before each measurement below
def randomized_quicksort(items: list[int], lo: int = 0, hi: int | None = None) -> None:
global comparisons # => this function mutates the module-level counter
if hi is None: # => top-level call defaults hi to the last index
hi = len(items) - 1 # => sorts the WHOLE list on the first call
if lo < hi: # => base case: 0 or 1 elements need no partitioning
p = random_pivot_partition(items, lo, hi) # => the only change from Example 8
randomized_quicksort(items, lo, p - 1) # => recurses on the left partition
randomized_quicksort(items, p + 1, hi) # => recurses on the right partition
def random_pivot_partition(items: list[int], lo: int, hi: int) -> int:
global comparisons # => mutates the shared counter
rand_index = random.randint(lo, hi) # => picks a UNIFORMLY random index as pivot
items[rand_index], items[lo] = ( # => opens the swap-to-front tuple assignment
items[lo], # => the old first element moves to the random index's old slot
items[rand_index], # => the randomly chosen value moves to the front
) # => swaps it to the front so the rest of the logic is unchanged
pivot = items[lo] # => now behaves exactly like Example 8's first-pivot partition
i = lo # => boundary of the "<pivot" region
for j in range(lo + 1, hi + 1): # => scans every element after the pivot
comparisons += 1 # => counts this one comparison against the pivot
if items[j] < pivot: # => belongs strictly before the pivot
i += 1 # => grows the "<pivot" region
items[i], items[j] = items[j], items[i] # => swaps it in
items[lo], items[i] = items[i], items[lo] # => places the pivot at its final spot
return i # => pivot's final index
random.seed(99) # => fixed seed -- makes the "random" pivot choices reproducible
n = 500 # => how many elements to sort
already_sorted: list[int] = list( # => opens the pre-sorted input construction
range(n) # => builds 0, 1, 2, ..., n-1 in ascending order
) # => Example 8's worst case: pre-sorted input
naive_worst_case = n * (n - 1) // 2 # => what Example 8 would score on this same input
comparisons = 0 # => resets the shared counter before measuring
randomized_quicksort(already_sorted) # => sorts the SAME kind of worst-case input
print(comparisons < naive_worst_case) # => Output: True
print(already_sorted == list(range(n))) # => Output: True -- still sorts correctly
assert ( # => opens the check -- wraps the long boolean across two lines
comparisons < naive_worst_case # => True only if randomization avoided the blow-up
) # => confirms randomization avoids the O(n^2) sorted-input trap
assert already_sorted == list(range(n)) # => confirms correctness is unaffected
print("ex-27 OK") # => Output: ex-27 OKRun: python3 example.py
Output:
True
True
ex-27 OKlearning/code/ex-27-quicksort-random-pivot/test_example.py
"""Example 27: pytest verification for Randomized-Pivot Quicksort."""
import random
import example
def test_sorted_input_no_longer_hits_the_naive_worst_case() -> None:
random.seed(3)
n = 300
data: list[int] = list(range(n))
example.comparisons = 0
example.randomized_quicksort(data)
naive_worst_case = n * (n - 1) // 2
assert example.comparisons < naive_worst_case # => far below the O(n^2) bound
assert data == list(range(n)) # => still correctly sorted
def test_matches_sorted_on_random_input() -> None:
random.seed(4)
data: list[int] = random.sample(range(1000), 60)
expected = sorted(data)
example.randomized_quicksort(data)
assert data == expected
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Randomizing pivot choice does not change quicksort's WORST-CASE complexity (an adversary who knows the random seed could still force it), but it makes that worst case exponentially unlikely for any FIXED input, including already-sorted data.
Why it matters: This is the standard, practical fix for quicksort's Example-8 weakness, and it is why real-world quicksort implementations use randomization (or a similar trick like median-of-three). It is also an important distinction from Example 74's median-of-medians, which achieves a worst-case guarantee DETERMINISTICALLY, without relying on randomness at all.
Example 28: Quickselect for the k-th Smallest
ex-28 · exercises co-08
Quickselect reuses quicksort's partition step but recurses into only ONE side -- whichever side contains the k-th position -- instead of both. This example finds several different ranks in an array and checks each result against sorted(arr)[k].
learning/code/ex-28-quickselect-kth/example.py
"""Example 28: Quickselect -- the k-th Smallest Element in Expected O(n)."""
# Quickselect (co-08) reuses quicksort's partition step, but RECURSES INTO
# ONLY ONE SIDE -- whichever side contains the k-th position -- instead of
# both, giving expected O(n) instead of a full O(n log n) sort.
import random
def quickselect( # => recurses into only the side containing rank k, not both sides
items: list[int], # => the array to search within (never mutated by the caller)
k: int, # => k is the 0-indexed target rank to find
) -> int: # => returns the k-th smallest (0-indexed)
working = list(items) # => a copy -- the caller's list is never mutated
lo, hi = 0, len(working) - 1 # => the active search range, shrinks each round
while True: # => each iteration eliminates one whole side of the partition
if lo == hi: # => only one candidate remains -- it must be the answer
return working[lo] # => base case: nowhere left to search
p = random_pivot_partition(working, lo, hi) # => the pivot's final sorted index
if p == k: # => the pivot itself landed exactly at the target rank
return working[p] # => found it -- no more recursion needed
if p < k: # => the k-th smallest is somewhere to the RIGHT of the pivot
lo = p + 1 # => discards the entire left side -- it's already too small
else: # => the k-th smallest is somewhere to the LEFT of the pivot
hi = p - 1 # => discards the entire right side -- it's already too big
def random_pivot_partition(
items: list[int], lo: int, hi: int
) -> int: # => Lomuto scheme
rand_index = random.randint(lo, hi) # => a uniformly random pivot choice
items[rand_index], items[lo] = items[lo], items[rand_index] # => moves it to front
pivot = items[lo] # => the value being partitioned around
i = lo # => boundary of the "<pivot" region
for j in range(lo + 1, hi + 1): # => scans the rest of the active range
if items[j] < pivot: # => belongs strictly before the pivot
i += 1 # => grows the "<pivot" region by one slot
items[i], items[j] = items[j], items[i] # => swaps it into place
items[lo], items[i] = items[i], items[lo] # => places the pivot at its final index
return i # => the pivot's final, correctly-sorted-position index
# a fixed seed makes this whole demo fully reproducible across runs
random.seed(17) # => fixed seed -> reproducible pivot choices
data: list[int] = random.sample(range(1000), 40) # => 40 distinct random ints
sorted_data = sorted(data) # => ground truth to check quickselect against
third_smallest = quickselect(data, k=2) # => 0-indexed: k=2 means the 3rd smallest
median = quickselect(data, k=len(data) // 2) # => the middle element by rank
print(third_smallest == sorted_data[2]) # => Output: True
print(median == sorted_data[len(data) // 2]) # => Output: True
assert third_smallest == sorted_data[2] # => confirms rank-2 matches sorted()[2]
assert median == sorted_data[len(data) // 2] # => confirms the median rank matches too
assert quickselect(data, k=0) == min(data) # => rank 0 is always the minimum
assert quickselect(data, k=len(data) - 1) == max(data) # => the last rank is the max
print("ex-28 OK") # => Output: ex-28 OKRun: python3 example.py
Output:
True
True
ex-28 OKlearning/code/ex-28-quickselect-kth/test_example.py
"""Example 28: pytest verification for Quickselect."""
import random
from example import quickselect
def test_every_rank_matches_sorted_index() -> None:
random.seed(6)
data: list[int] = random.sample(range(500), 25)
expected = sorted(data)
for k in range(len(data)): # => checks EVERY rank, not just a couple samples
assert quickselect(data, k) == expected[k]
def test_does_not_mutate_the_caller_list() -> None:
data: list[int] = [5, 2, 8, 1]
original = list(data)
quickselect(data, k=1)
assert data == original # => the caller's list is untouched
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Quickselect finds the k-th smallest element in expected O(n) time, faster than fully sorting (O(n log n)), because discarding the side that cannot contain rank k means the recursion only ever processes ONE shrinking side, not both.
Why it matters: Quickselect is the textbook example of 'you don't need to sort the whole thing to answer one specific question about it' -- finding a median, a percentile, or the k-th ranked value is a strictly easier problem than a full sort, and quickselect's single-sided recursion is what captures that savings. Example 74 pushes this idea further with a worst-case guarantee.
Example 29: Closest Pair of Points
ex-29 · exercises co-06
Divide-and-conquer beats brute force's O(n^2) pairwise scan by splitting points on the x-axis, solving each half recursively, then only re-checking a thin vertical strip near the split for cross-boundary pairs. This example finds the closest pair among a set of 2D points and checks it against brute force.
learning/code/ex-29-closest-pair-divide-conquer/example.py
"""Example 29: Closest Pair of Points -- Divide and Conquer vs Brute Force."""
# Divide-and-conquer (co-06) beats brute force's O(n^2) by SPLITTING points on
# x, solving each half recursively, then only re-checking a thin vertical
# STRIP near the midline for cross-half pairs -- overall O(n log n).
# Squared distances (int, no sqrt) are compared throughout -- ordering is
# identical to true distance, but no floating-point rounding risk.
Point = tuple[int, int] # => a 2D point as (x, y)
def squared_distance(p: Point, q: Point) -> int: # => (dx^2 + dy^2), no sqrt needed
return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 # => squared Euclidean distance
def brute_force_closest_pair(points: list[Point]) -> int: # => O(n^2): every pair
best = squared_distance(points[0], points[1]) # => a starting baseline
for i in range(len(points)): # => tries every unordered pair once
for j in range(i + 1, len(points)): # => avoids re-checking the same pair twice
best = min(best, squared_distance(points[i], points[j])) # => tracks min
return best # => the true minimum squared distance, by exhaustive comparison
def closest_pair_divide_conquer(points: list[Point]) -> int: # => O(n log n) overall
by_x = sorted(points, key=lambda p: p[0]) # => O(n log n), done once up front
return _closest_pair(by_x) # => delegates to the recursive divide step
def _closest_pair(points_by_x: list[Point]) -> int: # => points, already sorted by x
n = len(points_by_x) # => how many points remain in this recursive slice
if n <= 3: # => base case: brute force is cheap enough for 3 or fewer points
return brute_force_closest_pair(points_by_x) # => O(1) work at this size
mid = n // 2 # => the split point
mid_x = points_by_x[mid][0] # => the x-coordinate of the dividing line
left_best = _closest_pair(points_by_x[:mid]) # => recurses on the left half
right_best = _closest_pair(points_by_x[mid:]) # => recurses on the right half
best = min(left_best, right_best) # => the best purely-within-one-half distance
strip = [ # => opens the filtered "close to the midline" list comprehension
p
for p in points_by_x
if (p[0] - mid_x) ** 2 < best # => squared-x pruning
] # => points close enough to the midline to possibly beat `best`
strip.sort(key=lambda p: p[1]) # => sorting the (small) strip by y enables pruning
for i in range(len(strip)): # => checks each strip point against its NEAR neighbors
for j in range( # => opens the bounded inner-neighbor range
i + 1,
min(i + 8, len(strip)), # => caps the scan at 7 neighbors, never n
): # => a well-known bound: at most 7 useful neighbors in y-sorted order
best = min(best, squared_distance(strip[i], strip[j])) # => updates best
return best # => the true minimum squared distance across the whole point set
points: list[Point] = [ # => opens the 8-point set, deliberately mixing close/far pairs
(2, 3), # => part of the TRUE closest pair, alongside (3, 4)
(12, 30), # => far from the cluster near the origin
(40, 50), # => the most distant outlier point
(5, 1), # => moderately close to the origin cluster
(12, 10), # => sits between the near cluster and the mid-distance points
(3, 4), # => the other half of the TRUE closest pair, alongside (2, 3)
(0, 0), # => the origin -- anchors the near cluster
(20, 20), # => a mid-distance point, closer to the outliers than the cluster
] # => 8 points, deliberately mixing close and far pairs
brute_answer = brute_force_closest_pair(points) # => O(n^2) ground truth
fast_answer = closest_pair_divide_conquer(points) # => O(n log n) divide-and-conquer
print(brute_answer) # => Output: 2
print(fast_answer) # => Output: 2
assert brute_answer == fast_answer # => confirms both approaches agree exactly
assert brute_answer == squared_distance( # => opens the explicit ground-truth check
(2, 3),
(3, 4), # => the two points expected to be the closest pair
) # => confirms (2,3)-(3,4) is genuinely the closest pair: dist^2 = 1+1 = 2
print("ex-29 OK") # => Output: ex-29 OKRun: python3 example.py
Output:
2
2
ex-29 OKlearning/code/ex-29-closest-pair-divide-conquer/test_example.py
"""Example 29: pytest verification for Closest Pair Divide and Conquer."""
import random
from example import brute_force_closest_pair, closest_pair_divide_conquer
def test_matches_brute_force_on_random_points() -> None:
random.seed(31)
points = [
(random.randint(0, 200), random.randint(0, 200)) for _ in range(40)
] # => 40 random 2D points
assert closest_pair_divide_conquer(points) == brute_force_closest_pair(points)
def test_matches_brute_force_on_a_small_collinear_set() -> None:
points = [(0, 0), (1, 0), (5, 0), (9, 0)] # => all on one line -- an edge shape
assert closest_pair_divide_conquer(points) == brute_force_closest_pair(points)
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: The divide-and-conquer closest-pair algorithm's key insight is that the expensive-looking 'merge' step (checking cross-boundary pairs) only needs to examine a narrow strip around the split line, not the whole combined set -- that is what keeps the combine step cheap enough for an O(n log n) total bound.
Why it matters: This is one of the classic proofs that divide-and-conquer can beat the 'obvious' brute-force approach on a genuinely geometric problem, not just on sorting. Recognizing that a seemingly-global comparison (every pair of points) can be narrowed to a small local region after a smart split is a pattern that reappears in many computational-geometry and scheduling problems beyond this one example.
Example 30: Fenwick Tree Prefix Sum
ex-30 · exercises co-14
A Fenwick tree (binary indexed tree) stores partial sums keyed by the lowest set bit of each index, giving both point-update and prefix-sum queries in O(log n). This example runs a sequence of updates and prefix-sum queries and checks every answer against a plain running array.
learning/code/ex-30-fenwick-prefix-sum/example.py
"""Example 30: Fenwick Tree (Binary Indexed Tree) -- O(log n) Prefix Sum + Update."""
# A Fenwick tree (co-14) stores partial sums keyed by the LOWEST SET BIT of
# each index -- both point-update and prefix-sum become O(log n), beating a
# plain array's O(n) prefix-sum recompute and a running-array's O(n) update-shift.
class FenwickTree: # => 1-indexed internally -- index 0 is unused, by convention
def __init__(self, n: int) -> None: # => allocates a zeroed tree sized for n items
self.n = n # => the number of elements this tree covers
self.tree: list[int] = [0] * (n + 1) # => tree[i] holds a partial-sum range
def update(self, i: int, delta: int) -> None: # => O(log n): adds delta at index i
i += 1 # => converts the caller's 0-indexed position to 1-indexed internally
while i <= self.n: # => climbs toward the root, following the BIT structure
self.tree[i] += delta # => applies delta to this partial-sum node
i += i & (-i) # => jumps to the next node this index's range feeds into
# => `i & (-i)` isolates the lowest set bit -- the core BIT trick
def prefix_sum( # => walks DOWN the BIT structure, accumulating partial sums
self, # => the tree instance holding this Fenwick array
i: int, # => the (0-indexed) inclusive upper bound of the prefix
) -> int: # => O(log n): sum of elements [0, i] inclusive
i += 1 # => converts to 1-indexed
total = 0 # => accumulates the running sum
while i > 0: # => walks DOWN toward index 0, following the BIT structure
total += self.tree[i] # => adds this node's partial sum
i -= i & (-i) # => strips the lowest set bit, moving to the parent range
return total # => the sum of all elements from index 0 through i, inclusive
def range_sum(self, lo: int, hi: int) -> int: # => O(log n): sum of [lo, hi]
if lo == 0: # => no need to subtract anything below index 0
return self.prefix_sum(hi) # => the prefix sum IS the range sum
return self.prefix_sum(hi) - self.prefix_sum( # => two O(log n) lookups
lo - 1 # => excludes everything strictly below lo
) # => classic prefix-sum subtraction trick
values: list[int] = [3, 2, -1, 6, 5, 4, -3, 3, 7, 2] # => 10 starting values
n = len(values) # => n = 10
fenwick = FenwickTree(n) # => starts as all zeros
for idx, v in enumerate(values): # => O(n log n) total: one update per starting value
fenwick.update(idx, v) # => builds up the tree to reflect `values`
running_array = list(values) # => a plain array, kept in sync for cross-checking
print(fenwick.prefix_sum(4)) # => Output: 15 -- sum of values[0..4]
print(sum(running_array[: 4 + 1])) # => Output: 15 -- confirms the plain-array sum
fenwick.update(2, 10) # => adds 10 AT index 2 (a point update, not a set)
running_array[2] += 10 # => keeps the plain array in sync for comparison
print(fenwick.prefix_sum(4)) # => Output: 25 -- 15 + 10, reflecting the point update
print(fenwick.range_sum(3, 7)) # => Output: 15 -- sum of values[3..7] after the update
assert ( # => opens the Fenwick-vs-plain-sum cross-check
fenwick.prefix_sum(4)
== sum( # => cross-checks the Fenwick tree vs a plain sum
running_array[: 4 + 1] # => the same [0, 4] slice, summed the naive O(n) way
) # => closes the naive prefix sum call
) # => confirms Fenwick matches a plain re-sum after the update
assert ( # => opens the arbitrary-range cross-check
fenwick.range_sum(3, 7)
== sum( # => cross-checks an arbitrary mid-range sum
running_array[3 : 7 + 1] # => the same [3, 7] slice, summed the naive O(n) way
) # => closes the naive range sum call
) # => confirms arbitrary range sums match too
print("ex-30 OK") # => Output: ex-30 OKRun: python3 example.py
Output:
15
15
25
15
ex-30 OKlearning/code/ex-30-fenwick-prefix-sum/test_example.py
"""Example 30: pytest verification for the Fenwick Tree."""
import random
from example import FenwickTree
def test_matches_a_running_array_after_random_updates() -> None:
random.seed(41)
n = 30
running: list[int] = [0] * n
fenwick = FenwickTree(n)
for _ in range(100): # => 100 random point updates
idx = random.randint(0, n - 1)
delta = random.randint(-5, 5)
fenwick.update(idx, delta)
running[idx] += delta
lo = random.randint(0, n - 1)
hi = random.randint(lo, n - 1)
assert fenwick.range_sum(lo, hi) == sum(running[lo : hi + 1])
def test_prefix_sum_of_all_zeros_is_zero() -> None:
fenwick = FenwickTree(5)
assert fenwick.prefix_sum(4) == 0 # => nothing has been added yet
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A Fenwick tree's O(log n) update and query both come from the SAME bit trick -- i & (-i) isolates the lowest set bit, which determines exactly which partial sums each index contributes to and which ones a query must combine.
Why it matters: A plain array answers point-updates in O(1) but prefix-sum queries in O(n); a precomputed prefix-sum array flips that, O(1) query but O(n) update. A Fenwick tree is the space-efficient (co-05) middle ground -- both operations in O(log n), using only O(n) extra space, which is exactly why Example 67 compares it directly against a heavier segment tree.
Example 31: Segment Tree Range-Minimum Queries
ex-31 · exercises co-15
A segment tree stores, at each internal node, the aggregate (here, the minimum) of an entire array range, built once in O(n) and then answering any range-min query in O(log n). This example builds one over a fixed array and checks several range-min queries against brute-force slicing.
learning/code/ex-31-segment-tree-range-min/example.py
"""Example 31: Segment Tree -- O(log n) Range-Minimum Queries."""
# A segment tree (co-15) stores, at each internal node, the aggregate (here,
# the MIN) of an entire array range -- built once in O(n), then answering any
# range-min query in O(log n) by combining O(log n) precomputed sub-ranges.
INF = float( # => opens the sentinel construction
"inf" # => Python's built-in way to spell positive infinity as a float
) # => the segment tree's "empty range" sentinel, larger than any value
class SegmentTreeMin: # => a segment tree specialized for range-minimum queries
def __init__(self, data: list[int]) -> None: # => O(n): builds the whole tree once
self.n = len(data) # => the number of leaf elements
self.tree: list[float] = [INF] * ( # => opens the array-backed tree allocation
4 * self.n # => 4n is a standard safe upper bound on array-based tree size
) # => 4n is a standard safe upper bound on array-based tree size
self._build(data, 1, 0, self.n - 1) # => builds the tree rooted at index 1
def _build( # => recursively fills every node with its range's minimum, bottom-up
self, # => the tree instance under construction
data: list[int], # => the source array being indexed
node: int, # => this call's own array-backed tree index
lo: int, # => the low end of this call's range
hi: int, # => [lo, hi] is this call's range
) -> ( # => the return-type annotation, split across lines
None # => mutates self.tree in place -- nothing to return
): # => node uses the implicit-heap array encoding: children 2*node, 2*node+1
if lo == hi: # => a leaf: exactly one array element
self.tree[node] = data[lo] # => stores that element's own value directly
return # => nothing more to combine at a leaf
mid = (lo + hi) // 2 # => splits this range roughly in half
self._build(data, 2 * node, lo, mid) # => builds the left child (index 2*node)
self._build( # => opens the right-child build call
data, # => the same source array, threaded through
2 * node + 1, # => the right child's own tree index
mid + 1, # => one past the left child's range
hi, # => builds the right child (2*node+1)
) # => closes the right-child build call
self.tree[node] = min( # => this node's value is the min of its two children
self.tree[2 * node], # => left child's own min
self.tree[2 * node + 1], # => right child's own min
) # => combines children into this node's own min
def query(self, lo: int, hi: int) -> float: # => O(log n): min over [lo, hi]
# => the public entry point -- always starts recursion at node 1, full range
return self._query(1, 0, self.n - 1, lo, hi) # => starts the recursion at root
def _query( # => the classic three-way split: outside, inside, or partial overlap
self, # => the tree instance being queried
node: int, # => this call's own array-backed tree index
node_lo: int, # => this node's range's low end
node_hi: int, # => this node's range's high end
lo: int, # => the query range's low end
hi: int, # => node covers [node_lo, node_hi]
) -> float: # => the query range [lo, hi] never changes across the recursion
if hi < node_lo or node_hi < lo: # => this node's range is entirely OUTSIDE
return INF # => contributes nothing to a min -- INF is the identity
if lo <= node_lo and node_hi <= hi: # => this node's range is entirely INSIDE
return self.tree[node] # => the precomputed min covers this whole range
mid = (node_lo + node_hi) // 2 # => this node PARTIALLY overlaps -- must split
return min( # => combines whatever both children individually contribute
self._query( # => opens the left-half recursive call
2 * node, # => the left child's own tree index
node_lo, # => left child's range starts where this node's did
mid, # => left child's range ends at the midpoint
lo, # => the query range's low end, passed through unchanged
hi, # => recurses into the left half
), # => closes the left-half recursive call
self._query( # => opens the right-half recursive call
2 * node + 1, # => the right child's own tree index
mid + 1, # => right child's range starts just past the midpoint
node_hi, # => right child's range ends where this node's did
lo, # => the query range's low end, passed through unchanged
hi, # => and the right half
), # => closes the right-half recursive call
) # => combines whatever both halves contribute
# => 8 unsorted integers, min is 1 at index 3
data: list[int] = [ # => opens the source-array literal
5, # => index 0
2, # => index 1
8, # => index 2
1, # => index 3 -- the global minimum
9, # => index 4
3, # => index 5
7, # => index 6
4, # => index 7
] # => closes the source-array literal
tree = SegmentTreeMin(data) # => O(n): builds the tree once
print(tree.query(0, 3)) # => Output: 1 -- min of [5, 2, 8, 1]
print(tree.query(4, 7)) # => Output: 3 -- min of [9, 3, 7, 4]
print(tree.query(0, 7)) # => Output: 1 -- min of the whole array
assert tree.query(0, 3) == min(data[0:4]) # => confirms against a brute-force slice min
assert tree.query(4, 7) == min(data[4:8]) # => confirms another range against a slice
# => a single-element range is the INSIDE case -- returns the leaf's own value directly
assert tree.query(2, 2) == data[2] # => a single-element range returns that element
print("ex-31 OK") # => Output: ex-31 OKRun: python3 example.py
Output:
1
3
1
ex-31 OKlearning/code/ex-31-segment-tree-range-min/test_example.py
"""Example 31: pytest verification for Segment Tree Range-Min."""
import random
from example import SegmentTreeMin
def test_matches_brute_force_slice_min_on_random_ranges() -> None:
random.seed(51)
data = [random.randint(-50, 50) for _ in range(25)]
tree = SegmentTreeMin(data)
for _ in range(60):
lo = random.randint(0, len(data) - 1)
hi = random.randint(lo, len(data) - 1)
assert tree.query(lo, hi) == min(data[lo : hi + 1])
def test_whole_array_query_matches_python_min() -> None:
data = [4, -2, 9, 0, 7]
tree = SegmentTreeMin(data)
assert tree.query(0, len(data) - 1) == min(data)
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A segment tree answers a range-aggregate query in O(log n) by combining at most O(log n) precomputed subtree results -- never touching more than a logarithmic number of the tree's O(n) nodes for any single query.
Why it matters: Unlike a Fenwick tree (Example 30), which only supports sum-like aggregates, a segment tree generalizes to ANY associative operation -- min, max, GCD, or sum -- which is exactly why it is the right structure for range-min here, a query a Fenwick tree cannot answer directly. That generality costs more code and roughly 4x the memory, a tradeoff Example 67 makes explicit.
Example 32: Segment Tree with Lazy Range-Add
ex-32 · exercises co-15
A plain segment tree's range update touches every leaf in the range: O(n) in the worst case. Lazy propagation defers that work, stamping a fully-covered node with a pending update instead of pushing it all the way down immediately. This example applies several range-add updates and verifies point reads afterward.
learning/code/ex-32-segment-tree-range-update/example.py
"""Example 32: Segment Tree with Lazy Range-Add -- O(log n) Range Update, Point Read."""
# A plain segment tree's range update is O(n) (touch every leaf). LAZY
# propagation (co-15) defers that work: a fully-covered node just stamps a
# pending "add" tag and stops -- the tag only gets PUSHED DOWN to children
# when a later query actually needs to look inside that node. O(log n) both ways.
class LazySegmentTree: # => sum-tracking tree with O(log n) range-add, point query
def __init__(self, data: list[int]) -> None: # => O(n): builds the initial tree
self.n = len(data) # => number of leaf elements
self.tree: list[int] = [0] * (4 * self.n) # => tree[node] = sum over its range
self.lazy: list[int] = [0] * (4 * self.n) # => pending, not-yet-pushed adds
self._build(data, 1, 0, self.n - 1) # => builds the initial tree, O(n)
def _build( # => bottom-up: leaves first, then each parent sums its two children
self, # => the tree instance under construction
data: list[int], # => the source array being indexed
node: int, # => this call's own array-backed tree index
lo: int, # => the low end of this node's range
hi: int, # => [lo, hi] is this node's range
) -> None: # => fills self.tree over the array-index encoding, no lazy tags yet
if lo == hi: # => leaf: exactly one element
self.tree[node] = data[lo] # => its own starting value
return # => nothing more to combine at a leaf
mid = (lo + hi) // 2 # => splits the range
self._build(data, 2 * node, lo, mid) # => builds the left child
self._build(data, 2 * node + 1, mid + 1, hi) # => builds the right child
self.tree[node] = ( # => opens the parent-sum assignment
self.tree[2 * node] # => the left child's own sum
+ self.tree[2 * node + 1] # => plus the right child's own sum
) # => this node's sum is its children's combined sum
def _push_down( # => flushes node's pending tag one level down, THEN clears it
self, # => the tree instance being flushed
node: int, # => this call's own array-backed tree index
lo: int, # => the low end of this node's range
hi: int, # => node's own [lo, hi] range
) -> None: # => flushes a pending tag
if self.lazy[node] == 0: # => nothing pending -- nothing to push
return # => an early exit avoids touching children unnecessarily
mid = (lo + hi) // 2 # => needed to size each child's range
for child, child_lo, child_hi in ( # => opens the (index, lo, hi) pair loop
(2 * node, lo, mid), # => the left child's index and range
(2 * node + 1, mid + 1, hi), # => the right child's index and range
): # => applies the SAME pending delta to both children
self.tree[child] += self.lazy[node] * ( # => scales the delta by range size
child_hi - child_lo + 1 # => how many elements this child's range spans
) # => scales by range SIZE, since tree[] stores a SUM, not a single value
self.lazy[child] += self.lazy[node] # => the child inherits the pending tag
self.lazy[node] = 0 # => this node's tag has now been fully passed down
def range_add( # => public entry point -- the only method callers use to update
self, # => the tree instance being updated
lo: int, # => the low end of the range to add to
hi: int, # => the high end of the range to add to
delta: int, # => adds delta to every index in [lo, hi]
) -> None: # => O(log n): adds delta
self._range_add(1, 0, self.n - 1, lo, hi, delta) # => starts at the root
def _range_add( # => the classic outside/inside/partial-overlap recursive split
self, # => the tree instance being updated
node: int, # => this call's own array-backed tree index
node_lo: int, # => this node's range's low end
node_hi: int, # => this node's range's high end
lo: int, # => the update range's low end
hi: int, # => the update range's high end
delta: int, # => node covers [node_lo, node_hi]
) -> None: # => mutates self.tree and self.lazy in place, returns nothing
if hi < node_lo or node_hi < lo: # => this node's range is entirely outside
return # => nothing to do here
if lo <= node_lo and node_hi <= hi: # => this node's range is entirely inside
self.tree[node] += delta * ( # => the aggregate sum shifts by delta * count
node_hi - node_lo + 1 # => how many elements this whole node covers
) # => updates the aggregate sum directly
self.lazy[node] += delta # => defers pushing to children until needed
return # => THE LAZY PART: children are not touched yet
self._push_down( # => must resolve any pending tag before recursing further
node, # => this node's own array-backed tree index
node_lo, # => this node's own range low end
node_hi, # => flushes THIS node's own stale tag first
) # => must resolve any pending tag before recursing further
mid = (node_lo + node_hi) // 2 # => splits this node's range for the recursion
self._range_add(2 * node, node_lo, mid, lo, hi, delta) # => recurses left
self._range_add(2 * node + 1, mid + 1, node_hi, lo, hi, delta) # => and right
self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1] # => recombines
def point_query(self, i: int) -> int: # => O(log n): the CURRENT value at index i
return self._point_query(1, 0, self.n - 1, i) # => starts at the root
def _point_query( # => descends one side at each level, pushing down tags first
self, # => the tree instance being queried
node: int, # => this call's own array-backed tree index
lo: int, # => this node's range's low end
hi: int, # => this node's range's high end
i: int, # => node covers [lo, hi]; i is the target
) -> int: # => returns the up-to-date value at index i
if lo == hi: # => a leaf -- its stored sum IS the single element's value
return self.tree[node] # => already reflects every applied range-add
self._push_down( # => resolves any pending tag before descending further
node, # => this node's own array-backed tree index
lo, # => this node's own range low end
hi, # => this node's own range, needed to size its children
) # => resolves any pending tag before descending further
mid = (lo + hi) // 2 # => decides which child holds index i
if i <= mid: # => the target index lives in the left half
return self._point_query(2 * node, lo, mid, i) # => recurses left
return self._point_query(2 * node + 1, mid + 1, hi, i) # => lives in the right
data: list[int] = [1, 2, 3, 4, 5, 6] # => 6 starting values
tree = LazySegmentTree(data) # => O(n): builds the initial tree
tree.range_add(1, 4, 10) # => O(log n): adds 10 to every element in indices [1, 4]
expected: list[int] = [ # => opens the hand-computed plain-array result
1, # => index 0 -- outside [1, 4], unchanged
12, # => index 1 -- 2 + 10
13, # => index 2 -- 3 + 10
14, # => index 3 -- 4 + 10
15, # => index 4 -- 5 + 10
6, # => index 5 -- outside [1, 4], unchanged
] # => the plain-array result of the same range-add, computed by hand
for i in range(len(data)): # => reads every index back through the tree
print(f"index {i}: {tree.point_query(i)}") # => Output: index N: value, per index
assert tree.point_query(i) == expected[i] # => confirms each point read is correct
tree.range_add(0, 2, 5) # => a SECOND, overlapping range-add, to stack lazy tags
expected2: list[int] = [6, 17, 18, 14, 15, 6] # => reflects both range-adds combined
for i in range(len(data)): # => re-reads every index after the second update
assert tree.point_query(i) == expected2[i] # => confirms overlapping updates stack
print("ex-32 OK") # => Output: ex-32 OKRun: python3 example.py
Output:
index 0: 1
index 1: 12
index 2: 13
index 3: 14
index 4: 15
index 5: 6
ex-32 OKlearning/code/ex-32-segment-tree-range-update/test_example.py
"""Example 32: pytest verification for Lazy Range-Add Segment Tree."""
import random
from example import LazySegmentTree
def test_matches_a_brute_force_running_array_after_random_range_adds() -> None:
random.seed(61)
n = 20
running: list[int] = [0] * n
tree = LazySegmentTree(running)
for _ in range(50): # => 50 random overlapping range-add operations
lo = random.randint(0, n - 1)
hi = random.randint(lo, n - 1)
delta = random.randint(-10, 10)
tree.range_add(lo, hi, delta)
for i in range(lo, hi + 1): # => the brute-force ground truth, applied directly
running[i] += delta
for i in range(n): # => re-checks every index after each update
assert tree.point_query(i) == running[i]
def test_non_overlapping_updates_stay_independent() -> None:
tree = LazySegmentTree([0, 0, 0, 0])
tree.range_add(0, 1, 5)
tree.range_add(2, 3, -3)
assert [tree.point_query(i) for i in range(4)] == [5, 5, -3, -3]
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Lazy propagation turns an O(n) range update into O(log n) by stamping fully-covered nodes with a PENDING delta and only pushing it further down the tree the next time that subtree is actually visited -- deferred work, not skipped work.
Why it matters: Lazy propagation is a direct application of the space-time tradeoff (co-05): storing a small pending value at each node costs a little extra memory, but saves enormous amounts of redundant work whenever the SAME range gets updated (or read) repeatedly. This pattern -- defer work until it is actually needed -- reappears constantly in systems programming outside of just segment trees.
Example 33: Union-Find with Rank and Path Compression
ex-33 · exercises co-16, co-02
Two independent optimizations on top of Example 22's plain union-find: union-by-rank always attaches the shorter tree under the taller one, and path compression flattens every node on a find path to point directly at the root. This example runs thousands of operations and confirms near-constant amortized query time.
learning/code/ex-33-union-find-optimized/example.py
"""Example 33: Union-Find with Union-by-Rank and Path Compression."""
# Two independent optimizations on top of Example 22's plain union-find
# (co-16): UNION-BY-RANK always attaches the shorter tree under the taller
# one, keeping trees flat; PATH COMPRESSION flattens every node visited
# during find() to point directly at the root. Together they give amortized
# O(alpha(n)) per operation -- alpha is the inverse Ackermann function, which
# is under 5 for any n that could ever be represented in memory: effectively
# constant time in practice, though not literally O(1) in the strict sense.
class OptimizedUnionFind: # => union-find with both classic optimizations applied
def __init__(self, n: int) -> None: # => n singleton groups, each its own root
self.parent: list[int] = list( # => opens the initial parent-list construction
range(n) # => index i's parent starts as i itself -- n separate groups
) # => each element starts as its own root
self.rank: list[int] = [0] * n # => an upper bound on each tree's height
def find(self, x: int) -> int: # => amortized O(alpha(n)) with path compression
if self.parent[x] != x: # => x is not yet its own group's root
self.parent[x] = (
self.find( # => recurses first, THEN repoints on the way back
self.parent[
x
] # => climbs toward the root through x's current parent
) # => closes the recursive find() call
) # => PATH COMPRESSION: recurses to the root, then repoints x DIRECTLY at it
return self.parent[x] # => x's parent is now either itself, or the true root
def union(self, a: int, b: int) -> None: # => amortized O(alpha(n))
root_a = self.find(a) # => a's group root (path-compressed along the way)
root_b = self.find(b) # => b's group root
if root_a == root_b: # => already the same group -- nothing to merge
return # => a union with itself is a no-op
if ( # => opens the rank comparison
self.rank[root_a] < self.rank[root_b]
): # => UNION BY RANK: shorter under taller
self.parent[root_a] = ( # => opens the shorter-under-taller reassignment
root_b # => attaches the shorter tree under the taller
) # => closes the reassignment
elif self.rank[root_a] > self.rank[root_b]: # => the mirror comparison
self.parent[root_b] = root_a # => the mirror case
else: # => equal rank -- pick either, and the result grows one level taller
self.parent[root_b] = root_a # => arbitrarily attaches b's root under a's
self.rank[root_a] += 1 # => only NOW does the resulting tree's height grow
def total_find_depth( # => a diagnostic helper -- NOT part of the union-find API itself
uf: OptimizedUnionFind, # => the union-find structure being queried
n: int, # => the structure to measure, and its element count
) -> int: # => sums parent-hop counts
total = 0 # => accumulates hops across every element's find()
for x in range(n): # => checks every element once
depth = 0 # => counts hops from x up to its root
cur = (
x # => a local walker, so find()'s own compression isn't re-triggered here
)
while (
uf.parent[cur] != cur
): # => climbs until reaching a self-parent (the root)
cur = uf.parent[cur] # => one hop toward the root
depth += 1 # => tallies this hop
total += depth # => adds this element's hop count to the running total
return total # => the sum of all n elements' current depths
# => margin note: n stays large enough that even O(n) depth would be visible
n = 1000 # => a reasonably large element count, to make near-flat trees visible
uf = OptimizedUnionFind(n) # => n singleton groups
for i in range( # => opens the union-count range
n - 1 # => one union per consecutive pair -- n-1 total union calls
): # => chains everything into ONE big group, worst-case union order
uf.union(i, i + 1) # => unions consecutive elements, one after another
for x in range(n): # => forces every element's find() to run and compress its path
uf.find(x) # => after this loop, EVERY element points close to directly at the root
average_depth = ( # => opens the average-hops-per-element computation
total_find_depth(uf, n) / n # => total hops divided by the element count
) # => average remaining hops after compression
print(average_depth < 3.0) # => Output: True -- effectively flat, not O(n) deep
print(uf.find(0) == uf.find(n - 1)) # => Output: True -- all n elements are one group
# => without path compression, this 1000-element CHAIN union order would leave average
# => depth near n/2 -- the near-flat result below is entirely due to the optimizations
assert ( # => opens the near-flat-depth check
average_depth < 3.0 # => True only if the optimizations actually kept trees flat
) # => confirms near-constant depth despite a 1000-element chain
assert uf.find(0) == uf.find(n - 1) # => confirms the whole chain merged into one group
print("ex-33 OK") # => Output: ex-33 OKRun: python3 example.py
Output:
True
True
ex-33 OKlearning/code/ex-33-union-find-optimized/test_example.py
"""Example 33: pytest verification for Optimized Union-Find."""
from example import OptimizedUnionFind
def test_path_compression_keeps_trees_shallow_after_a_worst_case_chain() -> None:
n = 500
uf = OptimizedUnionFind(n)
for i in range(n - 1):
uf.union(i, i + 1) # => the worst possible union order for a naive union-find
for x in range(n):
uf.find(x) # => compresses every path
for x in range(n): # => re-checks: every element's parent should now be near-root
assert uf.parent[x] == uf.find(x) or uf.parent[x] == uf.parent[uf.find(x)]
def test_connectivity_after_optimized_unions_matches_expectations() -> None:
uf = OptimizedUnionFind(6)
uf.union(0, 1)
uf.union(2, 3)
uf.union(1, 2) # => merges {0,1} and {2,3} into one group of 4
assert uf.find(0) == uf.find(3) # => transitively connected
assert uf.find(0) != uf.find(4) # => 4 and 5 remain their own singleton groups
assert uf.find(4) != uf.find(5)
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Union-by-rank and path compression are INDEPENDENTLY useful, but together they give union-find its famous near-constant amortized cost per operation -- formally, O(inverse Ackermann, alpha(n)), a function that grows so slowly it is effectively constant for any input size that could ever occur in practice.
Why it matters: This is one of the most dramatic before/after contrasts in the topic: the same algorithm, with two small structural tweaks, goes from a structure that CAN degrade toward a linked list (Example 22) to one that is essentially constant-time at any realistic scale. Kruskal's MST (Example 42) directly depends on this optimized version to stay efficient.
Example 34: Count Connected Components via Union-Find
ex-34 · exercises co-16
Unioning every edge's two endpoints, and counting the distinct surviving roots afterward, identifies every connected component with no traversal needed at all. This example runs union-find over a graph with known components and confirms the count matches.
learning/code/ex-34-connected-components/example.py
"""Example 34: Count Connected Components via Union-Find."""
# Union every edge's two endpoints (co-16), and each surviving DISTINCT root
# afterward identifies one connected component -- no traversal needed at all,
# just a pass over the edges followed by counting unique find() results.
class UnionFind: # => the optimized version from Example 33, reused as-is
def __init__(self, n: int) -> None: # => n singleton groups, each its own root
self.parent: list[int] = list( # => opens the initial parent-array construction
range(n) # => index i's parent starts as i itself -- n separate groups
) # => each element starts as its own root
self.rank: list[int] = [0] * n # => an upper bound on each tree's height
def find(self, x: int) -> int: # => amortized O(alpha(n)) with path compression
if self.parent[x] != x: # => x is not yet its own group's root
self.parent[x] = (
self.find( # => recurses first, THEN repoints on the way back
self.parent[
x # => the element whose root is being sought
] # => climbs toward the root through x's current parent
) # => closes the recursive find() call
) # => path-compresses on the way back
return self.parent[x] # => x's parent is now either itself, or the true root
def union(self, a: int, b: int) -> None: # => amortized O(alpha(n))
root_a, root_b = self.find(a), self.find(b) # => both groups' roots, compressed
if root_a == root_b: # => already the same group -- nothing to merge
return # => a union with itself is a no-op
if ( # => opens the shorter-under-taller rank comparison
self.rank[root_a] < self.rank[root_b] # => a's tree is strictly shorter
): # => UNION BY RANK: shorter under taller
self.parent[root_a] = ( # => opens the shorter-under-taller reassignment
root_b # => attaches the shorter tree under the taller
) # => closes the reassignment
elif self.rank[root_a] > self.rank[root_b]: # => the mirror comparison
self.parent[root_b] = root_a # => the mirror case
else: # => equal rank -- pick either, and the result grows one level taller
self.parent[root_b] = root_a # => arbitrarily attaches b's root under a's
self.rank[root_a] += 1 # => only NOW does the resulting tree's height grow
def count_components( # => no traversal at all -- just union every edge, then count roots
n: int, # => the number of nodes, labeled 0..n-1
edges: list[tuple[int, int]], # => n nodes labeled 0..n-1, plus the edge list
) -> int: # => O((V+E) alpha(V))
uf = UnionFind(n) # => n nodes, each initially its own component
for a, b in edges: # => O(E): unions every edge's endpoints
uf.union(a, b) # => merges a's and b's components, if not already merged
roots = { # => opens the set-comprehension collecting distinct roots
uf.find(x) # => the compressed root of node x
for x in range(n) # => a set automatically discards duplicate roots
} # => O(V): the set of DISTINCT surviving roots
return len(roots) # => one component per distinct root
n = 8 # => 8 nodes, labeled 0..7
edges: list[ # => opens the edge-list type annotation
tuple[int, int] # => each edge is a pair of node indices
] = [ # => opens the edge list -- deliberately leaves node 7 isolated
(0, 1), # => connects 0 and 1 into one group
(1, 2), # => extends that group to include 2 -- {0, 1, 2}
(3, 4), # => a separate two-node group -- {3, 4}
(5, 6), # => another separate two-node group -- {5, 6}
] # => leaves 7 fully isolated
component_count = count_components(n, edges) # => how many separate groups exist
print(component_count) # => Output: 4 -- {0,1,2}, {3,4}, {5,6}, {7}
assert component_count == 4 # => confirms the four expected groups
assert count_components(5, []) == 5 # => no edges at all: every node is its own group
assert count_components(3, [(0, 1), (1, 2)]) == 1 # => a fully connected chain
print("ex-34 OK") # => Output: ex-34 OKRun: python3 example.py
Output:
4
ex-34 OKlearning/code/ex-34-connected-components/test_example.py
"""Example 34: pytest verification for Connected Components via Union-Find."""
from example import count_components
def test_disconnected_pairs_and_a_singleton() -> None:
assert count_components(8, [(0, 1), (1, 2), (3, 4), (5, 6)]) == 4
def test_fully_connected_graph_has_one_component() -> None:
edges = [(0, 1), (1, 2), (2, 3), (3, 4)]
assert count_components(5, edges) == 1
def test_no_edges_means_every_node_is_its_own_component() -> None:
assert count_components(4, []) == 4
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: Counting distinct roots after unioning every edge answers 'how many connected components does this graph have' without ever explicitly traversing the graph -- union-find's structure already encodes connectivity directly.
Why it matters: This is a good illustration of picking the right TOOL for a question: BFS/DFS (Examples 19-20) can also find connected components by traversal, but union-find answers the same question with a data structure built for exactly this kind of 'are these two things connected' query, which is also why Kruskal's MST (Example 42) reaches for it instead of a traversal-based approach.
Example 35: Topological Sort via Kahn's Algorithm
ex-35 · exercises co-18
Kahn's algorithm repeatedly removes nodes with in-degree zero -- nodes with no remaining unprocessed prerequisites -- appending each to the output order and decrementing its neighbors' in-degrees. This example runs Kahn's algorithm on a small DAG and confirms every edge respects the resulting order.
learning/code/ex-35-topological-sort-kahn/example.py
"""Example 35: Topological Sort via Kahn's Algorithm."""
# Kahn's algorithm (co-18) repeatedly removes nodes with IN-DEGREE ZERO --
# nodes with no remaining unprocessed prerequisites -- appending each to the
# result and decrementing its neighbors' in-degrees, until none remain.
from collections import deque # => O(1) popleft, unlike a plain list
def kahn_topological_sort( # => BFS-style: repeatedly peel off zero-in-degree nodes
graph: dict[str, list[str]], # => adjacency map: node -> list of nodes it points to
) -> list[str] | None: # => None if a cycle makes ordering impossible
in_degree: dict[str, int] = { # => opens the initial all-zero in-degree map
node: 0 for node in graph
} # => starts every node's in-degree at 0
for node in graph: # => O(V+E): counts how many edges point INTO each node
for neighbor in graph[
node # => this node's own outgoing edges
]: # => each outgoing edge increments the target's count
in_degree[neighbor] += 1 # => one more prerequisite for neighbor
queue: deque[str] = deque( # => opens the initial ready-queue construction
[node for node in graph if in_degree[node] == 0] # => the zero-in-degree nodes
) # => nodes with NO prerequisites can go first
order: list[str] = [] # => accumulates the resulting topological order
while queue: # => processes nodes in waves of "everything now unblocked"
node = queue.popleft() # => O(1): the next ready node
order.append(node) # => it has no remaining unprocessed prerequisites
for neighbor in graph[node]: # => "removes" node by decrementing its neighbors
in_degree[neighbor] -= 1 # => one fewer prerequisite for neighbor
if in_degree[neighbor] == 0: # => neighbor is now fully unblocked
queue.append(neighbor) # => schedules it for the next wave
if len(order) != len(graph): # => fewer nodes than expected means a CYCLE exists
return None # => a cycle prevents any valid topological order
return order # => a valid topological order: every edge points forward in the list
graph: dict[str, list[str]] = { # => a small build-dependency DAG
"compile": ["link"], # => must happen before "link"
"link": ["test"], # => must happen before "test"
"fetch_deps": ["compile"], # => the true starting point -- no prerequisites at all
"test": [], # => the terminal step -- nothing depends on it
} # => closes the dependency map -- 4 build steps, one linear chain
order = kahn_topological_sort(graph) # => a valid build order
print(order) # => Output: ['fetch_deps', 'compile', 'link', 'test']
assert order is not None # => confirms no cycle was detected
position = { # => opens the node -> index lookup, built from the result order
node: i # => this node's position within the final order
for i, node in enumerate(order) # => pairs each node with its position
} # => node -> its index in the order
assert position["fetch_deps"] < position["compile"] # => a dependency comes first
assert position["compile"] < position["link"] # => confirms edge direction is honored
assert position["link"] < position["test"] # => confirms the last edge too
print("ex-35 OK") # => Output: ex-35 OKRun: python3 example.py
Output:
['fetch_deps', 'compile', 'link', 'test']
ex-35 OKlearning/code/ex-35-topological-sort-kahn/test_example.py
"""Example 35: pytest verification for Kahn's Topological Sort."""
from example import kahn_topological_sort
def test_every_edge_points_forward_in_the_resulting_order() -> None:
graph = {"a": ["b", "c"], "b": ["d"], "c": ["d"], "d": []}
order = kahn_topological_sort(graph)
assert order is not None
position = {node: i for i, node in enumerate(order)}
for u, neighbors in graph.items():
for v in neighbors:
assert position[u] < position[v] # => u always precedes v
def test_a_valid_dag_produces_a_full_length_order() -> None:
graph = {"x": ["y"], "y": ["z"], "z": []}
order = kahn_topological_sort(graph)
assert order is not None
assert len(order) == 3
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Kahn's algorithm produces a valid topological order by always processing a node only after every one of its PREREQUISITES has already been processed -- tracked via in-degree counts that hit zero exactly when that condition is met.
Why it matters: Topological sort is the algorithm behind every 'what order should these tasks run in' question where some tasks depend on others -- build systems, package installers, and course-prerequisite graphs all reduce to this. Example 65's critical-path scheduler and Example 80's capstone-preview scheduler both build directly on top of a topological order.
Example 36: Topological Sort via DFS Finish-Time Ordering
ex-36 · exercises co-18, co-17
A DFS-based topological sort is the mirror image of Kahn's algorithm: run DFS, and reverse the order nodes finish in. A node finishes only after every node reachable from it has already finished. This example runs both Kahn's and the DFS-based sort on the same DAG and confirms both produce valid orderings.
learning/code/ex-36-topological-sort-dfs/example.py
"""Example 36: Topological Sort via DFS Finish-Time Ordering."""
# A DFS-based topological sort (co-18, co-17) is the mirror image of Kahn's
# algorithm: run DFS, and REVERSE the order nodes FINISH in. A node finishes
# only after every node reachable from it has already finished -- so it must
# come before all of them in a valid ordering.
def dfs_topological_sort( # => reverse of DFS finish order -- the mirror of Kahn's
graph: dict[str, list[str]], # => adjacency map: node -> list of nodes it points to
) -> list[str]: # => assumes a DAG -- no cycle check here (that's Example 37)
visited: set[str] = set() # => nodes already fully explored
finish_order: list[ # => opens the type annotation split across lines
str
] = [] # => nodes appended in the order they FINISH, not start
def recurse(node: str) -> None: # => a standard recursive DFS visit
visited.add(node) # => marks node as being explored
for neighbor in graph.get(node, []): # => visits every outgoing edge
if neighbor not in visited: # => only recurse into undiscovered nodes
recurse(neighbor) # => fully explores neighbor before returning
finish_order.append(node) # => node is appended ONLY after ALL its descendants
for node in graph: # => handles disconnected pieces too, not just one component
if node not in visited: # => starts a fresh DFS from any unvisited node
recurse(node) # => explores this whole component
return list(reversed(finish_order)) # => REVERSING finish order gives topo order
graph: dict[str, list[str]] = { # => the same build-dependency DAG as Example 35
"compile": ["link"], # => must happen before "link"
"link": ["test"], # => must happen before "test"
"fetch_deps": ["compile"], # => the true starting point -- no prerequisites at all
"test": [], # => the terminal step -- nothing depends on it
} # => closes the dependency map -- same 4 build steps as Example 35
order = dfs_topological_sort(graph) # => a valid build order, via DFS this time
print(order) # => Output: ['fetch_deps', 'compile', 'link', 'test']
position = {node: i for i, node in enumerate(order)} # => node -> its index in order
assert ( # => opens the first edge-direction check
position["fetch_deps"] < position["compile"] # => True iff "fetch_deps" comes first
) # => confirms edge direction honored
assert position["compile"] < position["link"] # => confirms another edge's direction
assert position["link"] < position["test"] # => confirms the last edge too
assert len(order) == len(graph) # => confirms every node appears exactly once
print("ex-36 OK") # => Output: ex-36 OKRun: python3 example.py
Output:
['fetch_deps', 'compile', 'link', 'test']
ex-36 OKlearning/code/ex-36-topological-sort-dfs/test_example.py
"""Example 36: pytest verification for DFS-Based Topological Sort."""
from example import dfs_topological_sort
def test_every_edge_points_forward_in_the_resulting_order() -> None:
graph = {"a": ["b", "c"], "b": ["d"], "c": ["d"], "d": []}
order = dfs_topological_sort(graph)
position = {node: i for i, node in enumerate(order)}
for u, neighbors in graph.items():
for v in neighbors:
assert position[u] < position[v]
def test_handles_a_disconnected_graph_with_two_components() -> None:
graph = {"a": ["b"], "b": [], "x": ["y"], "y": []}
order = dfs_topological_sort(graph)
assert set(order) == {"a", "b", "x", "y"} # => every node from both components
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Reversing DFS finish order works as a topological sort because a node can only finish AFTER every node it can reach has already finished -- so the node itself must come before all of them in the reversed order.
Why it matters: Having two independently-correct algorithms (Kahn's queue-based approach and DFS's finish-time approach) that answer the same question is a useful cross-check: if a graph is genuinely a DAG, both must agree on SOME valid order (though not necessarily the identical order, since multiple valid topological orders can exist for the same DAG).
Example 37: Detect a Cycle via DFS Coloring
ex-37 · exercises co-18, co-17
Three colors, not just visited/unvisited, are what makes cycle detection possible: WHITE (unseen), GRAY (on the current recursion stack), and BLACK (fully finished). This example runs colored DFS on both a cyclic and an acyclic graph and confirms only the cyclic one is flagged.
learning/code/ex-37-cycle-detection-directed/example.py
"""Example 37: Detect a Cycle in a Directed Graph via DFS Coloring."""
# THREE colors (co-17, co-18), not just visited/unvisited, are what makes
# cycle detection possible: WHITE (unseen), GRAY (on the CURRENT recursion
# path), BLACK (fully finished). A back edge to a GRAY node means the current
# path loops back on itself -- exactly what a cycle is.
from enum import Enum, auto # => Color is an Enum, not a bare string, for type safety
class Color(Enum): # => three DFS visitation states -- enables cycle detection
WHITE = auto() # => not yet discovered
GRAY = auto() # => currently on the recursion stack -- an ANCESTOR of this call
BLACK = auto() # => fully explored, off the recursion stack
def has_cycle( # => three-color DFS: a GRAY-to-GRAY edge means a back edge, i.e. a cycle
graph: dict[str, list[str]], # => adjacency map: node -> list of nodes it points to
) -> bool: # => True iff a directed cycle exists
color: dict[str, Color] = { # => opens the dict-comprehension initializing colors
node: Color.WHITE # => every node begins undiscovered
for node in graph # => every node starts undiscovered
} # => all start WHITE
def recurse(node: str) -> bool: # => True if a cycle is found reachable from node
color[node] = Color.GRAY # => node is now an ANCESTOR on this recursion path
for neighbor in graph.get(node, []): # => tries every outgoing edge
if color[neighbor] == Color.GRAY: # => THE TELLTALE SIGN: an edge back to
return True # => an ancestor still on the stack -- a genuine cycle
if color[
neighbor # => this neighbor's current visitation state
] == Color.WHITE and recurse( # => only recurse into unseen nodes
neighbor # => the unvisited neighbor to explore next
): # => explore unseen nodes
return True # => a cycle was found deeper in this branch
color[node] = Color.BLACK # => node is fully explored -- no longer an ancestor
return False # => no cycle found through this node
return any( # => True as soon as ANY unvisited component reports a cycle
recurse(node) # => explores each still-undiscovered component
for node in graph
if color[node] == Color.WHITE
) # => checks every component
acyclic_graph: dict[ # => opens the type annotation split across lines
str, list[str] # => same node/neighbor-list shape as every other graph example
] = { # => a valid DAG -- Examples 35/36's build order
"fetch_deps": ["compile"], # => the true starting point -- no prerequisites at all
"compile": ["link"], # => must happen before "link"
"link": ["test"], # => must happen before "test"
"test": [], # => the terminal step -- nothing depends on it
} # => closes the acyclic dependency map -- same DAG as Examples 35/36
cyclic_graph: dict[str, list[str]] = { # => the SAME shape, but with one edge reversed
"fetch_deps": ["compile"], # => still the nominal starting point
"compile": ["link"], # => still points forward to "link"
"link": ["test", "fetch_deps"], # => "link" points back to "fetch_deps" -- a cycle
"test": [], # => still a terminal step, uninvolved in the cycle
} # => closes the cyclic dependency map -- one back edge creates fetch_deps->compile->link->fetch_deps
print(has_cycle(acyclic_graph)) # => Output: False
print(has_cycle(cyclic_graph)) # => Output: True
assert ( # => opens the "acyclic graph correctly accepted" check
has_cycle(acyclic_graph) is False # => True only if no cycle was falsely detected
) # => confirms a valid DAG is correctly accepted
assert has_cycle(cyclic_graph) is True # => confirms the cycle is correctly detected
assert has_cycle({"a": ["a"]}) is True # => a self-loop is the smallest possible cycle
print("ex-37 OK") # => Output: ex-37 OKRun: python3 example.py
Output:
False
True
ex-37 OKlearning/code/ex-37-cycle-detection-directed/test_example.py
"""Example 37: pytest verification for Directed Cycle Detection."""
from example import has_cycle
def test_dag_reports_no_cycle() -> None:
graph = {"a": ["b", "c"], "b": ["d"], "c": ["d"], "d": []}
assert has_cycle(graph) is False
def test_cyclic_graph_reports_a_cycle() -> None:
graph = {"a": ["b"], "b": ["c"], "c": ["a"]} # => a -> b -> c -> a
assert has_cycle(graph) is True
def test_disconnected_dag_with_no_cycle_reports_false() -> None:
graph = {"a": ["b"], "b": [], "x": ["y"], "y": []}
assert has_cycle(graph) is False
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: A back edge -- an edge pointing to a node that is currently GRAY (on the active recursion stack, not yet finished) -- is the exact signature of a cycle; an edge to a BLACK (already-finished) node is perfectly fine and does not indicate a cycle.
Why it matters: A plain visited/unvisited boolean cannot distinguish 'this node is an ancestor of mine on the current path' from 'this node was already fully explored on a completely different branch' -- and that distinction is exactly what separates a genuine cycle from a harmless shared descendant. Topological sort (Examples 35-36) is only well-defined for a DAG, which is why cycle detection is the necessary precondition check before trusting either sort's output.
Example 38: Dijkstra's Shortest Paths
ex-38 · exercises co-19
Dijkstra greedily expands the cheapest-known frontier node next, using a min-heap to find that node in O(log n) instead of an O(n) linear scan. This example runs Dijkstra on a weighted graph and confirms the resulting shortest distances match a hand-computed answer.
learning/code/ex-38-dijkstra-heap/example.py
"""Example 38: Dijkstra's Shortest Paths with a heapq Priority Queue."""
# Dijkstra (co-19) greedily expands the CHEAPEST-known frontier node next,
# using a min-heap (co-09) to find that node in O(log n) instead of an O(n)
# linear scan. Requires NON-NEGATIVE edge weights -- Example 40 shows why.
import heapq # => the min-heap priority queue used to pick the cheapest frontier node
def dijkstra( # => greedily finalizes the cheapest-known frontier node each iteration
graph: dict[str, list[tuple[str, int]]], # => node -> list of (neighbor, weight)
start: str, # => weighted adjacency + origin
) -> dict[str, float]: # => node -> shortest distance from start
distances: dict[str, float] = { # => opens the initial all-infinity distance map
node: float("inf") # => every node starts unreachable, by default
for node in graph # => every node starts unreachable
} # => everyone starts at infinity
distances[start] = 0 # => the start node is trivially 0 away from itself
heap: list[
tuple[float, str] # => (distance, node) pairs, ordered by distance
] = [ # => opens the initial single-entry priority queue
(0, start) # => the only known reachable node at distance 0
] # => (distance, node) -- heapq sorts by distance
visited: set[str] = set() # => nodes whose shortest distance is FINAL
while heap: # => processes the frontier until nothing remains
dist, node = heapq.heappop(heap) # => the currently cheapest unfinalized node
if node in visited: # => a stale heap entry -- a shorter path already finalized
continue # => skip it, no new information here
visited.add(node) # => node's distance is now FINAL -- never improves further
for neighbor, weight in graph[node]: # => relaxes every outgoing edge
new_dist = dist + weight # => the cost of reaching neighbor THROUGH node
if new_dist < distances[neighbor]: # => a strictly better path was found
distances[neighbor] = new_dist # => records the improved distance
heapq.heappush(heap, (new_dist, neighbor)) # => schedules the candidate
return distances # => shortest distance to every node reachable from start
graph: dict[str, list[tuple[str, int]]] = { # => node -> list of (neighbor, weight)
"a": [("b", 4), ("c", 1)], # => a's direct routes: to b (cost 4), to c (cost 1)
"b": [("d", 1)], # => b's only route: to d (cost 1)
"c": [("b", 2), ("d", 5)], # => c offers a cheaper detour to b than a's direct edge
"d": [], # => the terminal node -- no outgoing edges
} # => closes the weighted adjacency map -- 4 nodes
distances = dijkstra(graph, "a") # => shortest distances from "a" to every other node
print(distances) # => Output: {'a': 0, 'b': 3, 'c': 1, 'd': 4}
assert distances["a"] == 0 # => the start node is 0 away from itself
assert distances["b"] == 3 # => a->c->b (1+2=3) beats the direct a->b edge (cost 4)
assert distances["d"] == 4 # => a->c->b->d (1+2+1=4) is the cheapest route to d
print("ex-38 OK") # => Output: ex-38 OKRun: python3 example.py
Output:
{'a': 0, 'b': 3, 'c': 1, 'd': 4}
ex-38 OKlearning/code/ex-38-dijkstra-heap/test_example.py
"""Example 38: pytest verification for Dijkstra with a Heap."""
from example import dijkstra
def test_finds_shortest_path_through_an_indirect_route() -> None:
graph = {
"a": [("b", 4), ("c", 1)],
"b": [("d", 1)],
"c": [("b", 2), ("d", 5)],
"d": [],
}
distances = dijkstra(graph, "a")
assert distances["b"] == 3 # => indirect a->c->b beats the direct edge
def test_start_node_distance_is_zero() -> None:
graph = {"x": [("y", 5)], "y": []}
assert dijkstra(graph, "x")["x"] == 0
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Dijkstra's greedy strategy -- always finalize the closest unvisited node next -- is only CORRECT because every edge weight is non-negative; a negative edge could make a currently 'farther' path actually shorter, breaking the greedy assumption.
Why it matters: Dijkstra is the default shortest-path algorithm for the overwhelming majority of real-world weighted graphs (road networks, network routing) precisely because non-negative weights are the common case, and its heap-driven O((V+E) log V) beats Bellman-Ford's O(V*E) substantially. Example 40 shows exactly what breaks once negative edges enter the picture.
Example 39: Dijkstra Reports Infinity for Unreachable Nodes
ex-39 · exercises co-19
Initializing every distance to infinity before running Dijkstra means a node that is never relaxed simply keeps its infinite distance -- no special-case crash handling needed. This example runs Dijkstra on a graph with a deliberately unreachable node and confirms it reports infinity cleanly.
learning/code/ex-39-dijkstra-unreachable/example.py
"""Example 39: Dijkstra Reports Infinity for an Unreachable Node -- Never Crashes."""
# Initializing every distance to infinity BEFORE running Dijkstra (co-19)
# means a node that's never relaxed simply keeps its infinite distance --
# there's no special-case branch needed, and no risk of a KeyError or crash.
import heapq
def dijkstra( # => identical to Example 38's implementation
graph: dict[str, list[tuple[str, int]]],
start: str, # => weighted adjacency + origin
) -> dict[str, float]: # => identical to Example 38's implementation
distances: dict[str, float] = { # => every node starts unreachable, by design
node: float("inf")
for node in graph # => "island" gets this same sentinel too
} # => closes the dict-comprehension
distances[start] = 0 # => the start node is trivially 0 away from itself
heap: list[tuple[float, str]] = [(0, start)] # => (distance, node) priority queue
visited: set[str] = set() # => nodes whose shortest distance is FINAL
while heap: # => processes the frontier until nothing remains
dist, node = heapq.heappop(heap) # => the currently cheapest unfinalized node
if node in visited: # => a stale heap entry -- a shorter path already finalized
continue # => skip it, no new information here
visited.add(node) # => node's distance is now FINAL -- never improves further
for neighbor, weight in graph[node]: # => relaxes every outgoing edge
new_dist = dist + weight # => the cost of reaching neighbor THROUGH node
if new_dist < distances[neighbor]: # => a strictly better path was found
distances[neighbor] = new_dist # => records the improved distance
heapq.heappush(heap, (new_dist, neighbor)) # => schedules the candidate
return distances # => "island" is never touched -- stays at its initial infinity
graph: dict[str, list[tuple[str, int]]] = { # => "island" has NO incoming edge from "a"
"a": [("b", 2)], # => a's only route: to b (cost 2)
"b": [], # => a dead end, but still reachable from "a"
"island": [], # => completely disconnected from "a"'s component
} # => closes the adjacency map -- "island" is a member with zero incoming edges
distances = dijkstra(graph, "a") # => runs Dijkstra from "a"
print(distances["a"]) # => Output: 0
print(distances["b"]) # => Output: 2
print( # => opens the print call for the unreachable node's distance
distances["island"] # => still the original float("inf") sentinel
) # => Output: inf -- never relaxed, stays at its initial value
assert distances["b"] == 2 # => confirms the reachable node got its correct distance
assert distances["island"] == float( # => opens the unreachable-node check
"inf" # => the exact sentinel value every distance was initialized to
) # => confirms the unreachable node reports infinity, not a crash or missing key
assert "island" in distances # => confirms the key still exists -- no KeyError risk
print("ex-39 OK") # => Output: ex-39 OKRun: python3 example.py
Output:
0
2
inf
ex-39 OKlearning/code/ex-39-dijkstra-unreachable/test_example.py
"""Example 39: pytest verification for Dijkstra on an Unreachable Node."""
import math
from example import dijkstra
def test_unreachable_node_reports_infinity_not_an_exception() -> None:
graph = {"a": [("b", 1)], "b": [], "z": []} # => z has no edge from a's component
distances = dijkstra(graph, "a")
assert math.isinf(distances["z"]) # => infinity, never a raised exception
def test_reachable_nodes_still_get_finite_distances() -> None:
graph = {"a": [("b", 3)], "b": [], "z": []}
distances = dijkstra(graph, "a")
assert distances["b"] == 3
assert not math.isinf(distances["b"])
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Initializing distances to infinity is not just a placeholder value -- it is what makes 'this node is unreachable' fall out naturally from the algorithm's own logic, rather than requiring a separate reachability check bolted on afterward.
Why it matters: This is a small but important robustness lesson: choosing the right SENTINEL value (infinity, here) for 'not yet determined' can make an entire class of edge case disappear on its own, instead of needing explicit handling. A real shortest-path service that crashes or throws on a disconnected graph is a much worse failure mode than one that simply reports 'unreachable.'
Example 40: Bellman-Ford with Negative Edges
ex-40 · exercises co-20
Bellman-Ford relaxes every edge, V-1 times over -- slower than Dijkstra's O((V+E) log V), at O(V*E), but it tolerates negative edge weights that would break Dijkstra's greedy assumption. This example runs Bellman-Ford on a graph with a negative edge and confirms the resulting distances are correct.
learning/code/ex-40-bellman-ford-negative-edges/example.py
"""Example 40: Bellman-Ford -- Correct Shortest Paths with Negative Edges."""
# Bellman-Ford (co-20) relaxes EVERY edge, V-1 times over -- slower than
# Dijkstra's O((V+E) log V), at O(V*E), but it TOLERATES negative edge
# weights, which would silently give Dijkstra's greedy heap the wrong answer.
def bellman_ford( # => brute-force relax-every-edge, repeated V-1 times, no heap needed
n: int, # => the number of nodes, labeled 0..n-1
edges: list[
tuple[int, int, int] # => each edge is a (from, to, weight) triple
], # => (from, to, weight) triples, negatives allowed
start: int, # => node count, edges, origin
) -> list[float]: # => edges: (from, to, weight); returns dist[i] for each node
dist: list[float] = [float("inf")] * n # => every node starts at infinity
dist[start] = 0 # => the start node is 0 away from itself
for _ in range(n - 1): # => V-1 full passes -- the longest possible SIMPLE path
for u, v, w in edges: # => relaxes every edge, every pass
if dist[u] + w < dist[v]: # => found a strictly cheaper way to reach v
dist[v] = dist[u] + w # => updates v's distance
return dist # => shortest distance to every node, correct even with negative edges
n = 5 # => 5 nodes, labeled 0..4
edges: list[tuple[int, int, int]] = [ # => includes a NEGATIVE edge weight (3 -> 2, -6)
(0, 1, 6), # => 0 to 1, cost 6
(0, 2, 7), # => 0 to 2, cost 7
(1, 2, 8), # => 1 to 2, cost 8
(1, 3, 5), # => 1 to 3, cost 5
(
1, # => the edge's source node
4, # => the edge's destination node
-4, # => the negative weight itself
), # => 1 to 4, a NEGATIVE edge -- Dijkstra could not handle this correctly
(2, 3, -3), # => 2 to 3, another negative edge
(2, 4, 9), # => 2 to 4, cost 9
(3, 1, -2), # => 3 to 1, a negative edge feeding back into an earlier node
(4, 3, 7), # => 4 to 3, cost 7
(4, 0, 2), # => 4 to 0, closes a cycle back to the start -- but NOT a negative one
] # => closes the edge list -- 10 directed edges, 3 of them negative-weight
distances = bellman_ford(n, edges, start=0) # => shortest distances from node 0
print(distances) # => Output: [0, 2, 7, 4, -2]
assert distances[0] == 0 # => the start node is 0 away from itself
assert ( # => opens the "cheaper indirect path wins" check
distances[1] == 2
) # => reached via 0->2->3->1 (7-3-2=2), beats the direct edge (6)
assert distances[4] == -2 # => the negative edge 1->4 pulls this distance below zero
# => a negative-weight EDGE (like 1->4 at -4) doesn't imply a negative CYCLE --
# => Bellman-Ford handles this correctly; Example 41 is what a real cycle looks like
print("ex-40 OK") # => Output: ex-40 OKRun: python3 example.py
Output:
[0, 2, 7, 4, -2]
ex-40 OKlearning/code/ex-40-bellman-ford-negative-edges/test_example.py
"""Example 40: pytest verification for Bellman-Ford with Negative Edges."""
from example import bellman_ford
def test_matches_a_known_shortest_path_answer_with_negative_edges() -> None:
n = 5
edges = [
(0, 1, 6),
(0, 2, 7),
(1, 2, 8),
(1, 3, 5),
(1, 4, -4),
(2, 3, -3),
(2, 4, 9),
(3, 1, -2),
(4, 3, 7),
(4, 0, 2),
]
distances = bellman_ford(n, edges, start=0)
assert distances == [0, 2, 7, 4, -2]
def test_positive_only_graph_matches_a_simple_hand_computed_case() -> None:
edges = [(0, 1, 1), (1, 2, 1), (0, 2, 5)] # => 0->1->2 (2) beats direct 0->2 (5)
distances = bellman_ford(3, edges, start=0)
assert distances == [0, 1, 2]
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Bellman-Ford's V-1 relaxation rounds are exactly enough: any shortest path in a graph without a negative cycle visits at most V-1 edges, so V-1 full rounds of relaxing every edge are guaranteed to have propagated every shortest distance to its final value.
Why it matters: Bellman-Ford's extra generality over Dijkstra -- tolerating negative edges -- comes at a real cost, O(V*E) versus O((V+E) log V), which is exactly the speed/generality tradeoff Example 63 measures directly. Choosing between the two is a real engineering decision: use Dijkstra when weights are guaranteed non-negative, fall back to Bellman-Ford only when they might not be.
Example 41: Detect a Negative Cycle
ex-41 · exercises co-20
After V-1 relaxation rounds, distances are final -- unless a negative cycle exists, in which case an Nth round can still improve some distance, since a negative cycle lets a path get arbitrarily cheaper by looping through it more times. This example runs an extra relaxation round and flags exactly that case.
learning/code/ex-41-bellman-ford-negative-cycle/example.py
"""Example 41: Detect a Negative Cycle on Bellman-Ford's Nth Relaxation Round."""
# After V-1 relaxation rounds, distances are final -- UNLESS a negative cycle
# exists, in which case an Nth round can STILL improve some distance (co-20).
# That single extra round is the whole detection mechanism: if anything still
# relaxes, "shortest path" is not even well-defined -- you could loop forever.
def bellman_ford_with_cycle_check( # => runs V-1 rounds, then ONE extra detection round
n: int, # => the number of nodes, labeled 0..n-1
edges: list[tuple[int, int, int]], # => (from, to, weight) triples
start: int, # => node count, edges, origin
) -> tuple[list[float], bool]: # => (distances, has_negative_cycle)
dist: list[float] = [float("inf")] * n # => every node starts at infinity
dist[start] = 0 # => the start node is 0 away from itself
for _ in range(n - 1): # => the normal V-1 relaxation rounds
for u, v, w in edges: # => relaxes every edge, every pass
if dist[u] + w < dist[v]: # => found a strictly cheaper way to reach v
dist[v] = ( # => opens the distance-improvement assignment
dist[u] + w # => the new, cheaper distance to v
) # => a genuine improvement, still within round V-1
has_negative_cycle = False # => assumes no negative cycle until proven otherwise
for u, v, w in edges: # => the EXTRA, Nth round -- pure detection, no more updates
if dist[u] + w < dist[v]: # => still improvable after V-1 rounds is IMPOSSIBLE
has_negative_cycle = True # => ...unless a negative cycle exists
break # => one detected violation is proof enough
return dist, has_negative_cycle # => distances are UNRELIABLE if the flag is True
n = 4 # => 4 nodes, labeled 0..3
edges_with_negative_cycle: list[ # => opens the negative-cycle edge-list annotation
tuple[int, int, int] # => each edge is a (from, to, weight) triple
] = [
(0, 1, 1), # => the only edge INTO the cycle -- reaches node 1 to start it off
(1, 2, -1), # => first leg of the cycle
(2, 3, -1), # => second leg of the cycle
(3, 1, -1), # => 1 -> 2 -> 3 -> 1 sums to -3: a genuine negative CYCLE
] # => closes the edge list -- the 1->2->3->1 loop keeps getting cheaper forever
_, has_cycle = bellman_ford_with_cycle_check( # => discards distances, keeps the flag
n, # => the node count
edges_with_negative_cycle, # => the graph containing the genuine negative cycle
start=0, # => the graph with a genuine negative cycle
) # => discards the (unreliable) distances
print(has_cycle) # => Output: True
edges_without_cycle: list[ # => opens the no-cycle edge-list annotation
tuple[int, int, int] # => same triple shape, but this graph never loops back
] = [
(0, 1, 1), # => same starting edge as before
(1, 2, -1), # => same negative edge as before
(2, 3, -1), # => same negative EDGES, but no cycle -- a simple path this time
] # => closes the edge list -- node 3 has no outgoing edge, so nothing loops back
_, no_cycle = bellman_ford_with_cycle_check( # => discards distances, keeps the flag
n, # => the node count
edges_without_cycle, # => the graph with negative edges but no cycle at all
start=0, # => the graph with negative edges but no cycle
) # => same discard pattern
print(no_cycle) # => Output: False
assert has_cycle is True # => confirms the genuine negative cycle is flagged
assert no_cycle is False # => confirms negative EDGES alone don't trigger a false flag
print("ex-41 OK") # => Output: ex-41 OKRun: python3 example.py
Output:
True
False
ex-41 OKlearning/code/ex-41-bellman-ford-negative-cycle/test_example.py
"""Example 41: pytest verification for Negative Cycle Detection."""
from example import bellman_ford_with_cycle_check
def test_detects_a_genuine_negative_cycle() -> None:
edges = [(0, 1, 1), (1, 2, -1), (2, 0, -1)] # => 0->1->2->0 sums to -1
_, has_cycle = bellman_ford_with_cycle_check(3, edges, start=0)
assert has_cycle is True
def test_negative_edges_without_a_cycle_are_not_flagged() -> None:
edges = [(0, 1, -5), (1, 2, -5)] # => a simple path, both edges negative
_, has_cycle = bellman_ford_with_cycle_check(3, edges, start=0)
assert has_cycle is False
def test_all_positive_weights_never_flag_a_cycle() -> None:
edges = [(0, 1, 2), (1, 2, 3), (2, 0, 4)] # => a positive-weight cycle is fine
_, has_cycle = bellman_ford_with_cycle_check(3, edges, start=0)
assert has_cycle is False
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: A negative cycle makes 'shortest path' meaningless (the cost can be driven arbitrarily low by looping the cycle more times) -- Bellman-Ford detects one by running ONE extra relaxation round past the guaranteed V-1 and checking whether any distance still improves.
Why it matters: This extra round is the entire reason Bellman-Ford is preferred over Dijkstra in domains like currency-arbitrage detection, where a 'negative cycle' literally represents a profitable trading loop -- Dijkstra has no equivalent mechanism to even notice a negative cycle exists, let alone flag it, since its greedy assumption breaks down before it would ever find one.
Example 42: Kruskal's Minimum Spanning Tree
ex-42 · exercises co-21, co-16
Kruskal's algorithm is greedy on edges: sort every edge by weight, then add each one unless it would create a cycle, checked via union-find. This example builds an MST on a weighted graph and confirms the total weight matches a known minimum.
learning/code/ex-42-mst-kruskal/example.py
"""Example 42: Kruskal's Minimum Spanning Tree, via Sorting + Union-Find."""
# Kruskal's algorithm (co-21) is GREEDY on edges: sort every edge by weight,
# then add each one UNLESS it would create a cycle -- union-find (co-16)
# answers "would this create a cycle?" in near-constant time via connected().
class UnionFind: # => the optimized version from Example 33
def __init__(self, n: int) -> None: # => n singleton groups, each its own root
self.parent: list[int] = list( # => opens the initial parent-list construction
range(n) # => index i's parent starts as i itself
) # => each element starts as its own root
self.rank: list[int] = [0] * n # => an upper bound on each tree's height
def find(self, x: int) -> int: # => amortized O(alpha(n)) with path compression
if self.parent[x] != x: # => x is not yet its own group's root
self.parent[x] = ( # => opens the path-compression reassignment
self.find( # => recurses first, THEN repoints on the way back
self.parent[
x # => the element whose root is being sought
] # => climbs toward the root through x's current parent
) # => closes the recursive find() call
) # => path-compresses on the way back
return self.parent[x] # => x's parent is now either itself, or the true root
def union( # => the cycle test IS the union: a failed union means "would cycle"
self, # => the union-find structure being mutated
a: int, # => the candidate edge's first endpoint
b: int, # => the two nodes this candidate edge would connect
) -> bool: # => returns True if a merge actually happened
root_a, root_b = self.find(a), self.find(b) # => both groups' roots, compressed
if root_a == root_b: # => already connected -- adding this edge would cycle
return False # => signals "do not add this edge"
if ( # => opens the rank comparison
self.rank[root_a] < self.rank[root_b] # => a's tree is strictly shorter
): # => UNION BY RANK: shorter under taller
self.parent[root_a] = ( # => opens the shorter-under-taller reassignment
root_b # => attaches the shorter tree under the taller
) # => closes the reassignment
elif self.rank[root_a] > self.rank[root_b]: # => the mirror comparison
self.parent[root_b] = root_a # => the mirror case
else: # => equal rank -- pick either, and the result grows one level taller
self.parent[root_b] = root_a # => arbitrarily attaches b's root under a's
self.rank[root_a] += 1 # => only NOW does the resulting tree's height grow
return True # => signals "this edge was safely added"
def kruskal_mst( # => sort-then-greedily-add, skipping any edge that would form a cycle
n: int, # => the number of nodes, labeled 0..n-1
edges: list[tuple[int, int, int]], # => node count and (u, v, weight) edges
) -> tuple[list[tuple[int, int, int]], int]: # => (MST edges, total weight)
sorted_edges = sorted( # => opens the ascending-by-weight sort
edges, # => the raw, unsorted candidate edges
key=lambda e: e[2], # => sorts by the weight field only
) # => O(E log E): cheapest edges first
uf = UnionFind(n) # => starts with n singleton components
mst_edges: list[tuple[int, int, int]] = [] # => accumulates the chosen edges
total_weight = 0 # => running sum of the MST's edge weights
for u, v, w in sorted_edges: # => greedily considers cheapest-first
if uf.union(u, v): # => only True if u and v were NOT already connected
mst_edges.append((u, v, w)) # => this edge is safe -- it can't form a cycle
total_weight += w # => tallies its weight into the MST total
return mst_edges, total_weight # => the MST's edges and its total weight
n = 5 # => 5 nodes, labeled 0..4
edges: list[tuple[int, int, int]] = [ # => (u, v, weight)
(0, 1, 2), # => the single cheapest edge -- picked first, always safe
(0, 3, 6), # => a mid-cost edge, picked only if it doesn't close a cycle
(1, 2, 3), # => second-cheapest -- picked early
(1, 3, 8), # => the most expensive edge -- likely rejected as redundant
(1, 4, 5), # => connects the otherwise-isolated node 4
( # => opens the alternate, pricier route to node 4
2, # => the edge's source node
4, # => the edge's destination node
7, # => this alternate route's weight
), # => an alternate, pricier route to node 4 -- rejected once 4 is connected
(3, 4, 9), # => the second-most expensive edge -- almost certainly rejected
] # => closes the edge list -- 5 nodes, 7 candidate edges, MST needs exactly 4
mst_edges, total_weight = kruskal_mst(n, edges) # => builds the minimum spanning tree
print(len(mst_edges)) # => Output: 4 -- an MST always has exactly n-1 edges
print(total_weight) # => Output: 16
assert len(mst_edges) == n - 1 # => confirms exactly n-1 edges -- a spanning tree
assert total_weight == 16 # => confirms the minimum possible total weight
print("ex-42 OK") # => Output: ex-42 OKRun: python3 example.py
Output:
4
16
ex-42 OKlearning/code/ex-42-mst-kruskal/test_example.py
"""Example 42: pytest verification for Kruskal's MST."""
from example import kruskal_mst
def test_mst_has_exactly_n_minus_1_edges() -> None:
n = 4
edges = [(0, 1, 1), (1, 2, 2), (2, 3, 3), (0, 3, 10)]
mst_edges, _ = kruskal_mst(n, edges)
assert len(mst_edges) == n - 1
def test_mst_total_weight_matches_known_minimum() -> None:
n = 5
edges = [
(0, 1, 2),
(0, 3, 6),
(1, 2, 3),
(1, 3, 8),
(1, 4, 5),
(2, 4, 7),
(3, 4, 9),
]
_, total = kruskal_mst(n, edges)
assert total == 16
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Kruskal's greedy choice -- always consider the globally cheapest remaining edge next -- is provably safe for MST because adding any edge that does not create a cycle can never make the final spanning tree worse, a property specific to the minimum-spanning-tree problem.
Why it matters: Kruskal's algorithm is a clean example of the greedy paradigm (co-22) actually being provably optimal, unlike Example 45's coin-change counterexample where greedy fails -- the difference is whether the problem has the 'greedy-choice property,' and MST genuinely does. Union-find (Example 33) is what makes the cycle check efficient enough for this to scale.
Example 43: Prim's Minimum Spanning Tree
ex-43 · exercises co-21, co-19
Prim's algorithm is greedy on nodes instead of edges: grow one tree from a start node, always adding the cheapest edge that connects the growing tree to a new node, tracked with a heap. This example builds an MST on the same graph as Example 42 and confirms the total weight matches Kruskal's.
learning/code/ex-43-mst-prim/example.py
"""Example 43: Prim's Minimum Spanning Tree, via a Heap."""
# Prim's algorithm (co-21) is GREEDY on nodes instead of edges: grow ONE tree
# from a start node, always adding the CHEAPEST edge that connects the
# growing tree to a new node -- a min-heap (co-09) finds that edge in O(log E).
import heapq # => the min-heap priority queue used to pick the cheapest frontier edge
def prim_mst( # => grows ONE tree from node 0, always via the cheapest frontier edge
n: int, # => the number of nodes, labeled 0..n-1
adjacency: dict[int, list[tuple[int, int]]], # => node count + weighted adjacency
) -> tuple[list[tuple[int, int, int]], int]: # => (MST edges, total weight)
in_tree: set[int] = {0} # => the growing tree starts as just node 0
mst_edges: list[tuple[int, int, int]] = [] # => accumulates (u, v, weight) chosen
total_weight = 0 # => running sum of the MST's edge weights
heap: list[tuple[int, int, int]] = [ # => (weight, from_node, to_node) candidates
(w, 0, v) # => (weight, from, to) so heapq sorts by weight automatically
for v, w in adjacency[0] # => every edge leaving the start node
] # => the initial frontier, before heapify imposes heap order
heapq.heapify(heap) # => O(E): arranges the initial candidate edges into heap order
while len(in_tree) < n: # => stops once every node has joined the tree
weight, u, v = heapq.heappop(heap) # => the cheapest candidate edge overall
if ( # => opens the stale-entry check
v in in_tree # => True if v was already added via an earlier, cheaper pop
): # => a stale entry -- v joined the tree via a cheaper edge already
continue # => skip it, no new information
in_tree.add(v) # => v now joins the growing tree
mst_edges.append((u, v, weight)) # => this edge is part of the MST
total_weight += weight # => tallies its weight
for neighbor, w in adjacency[v]: # => v's edges become new candidates
if ( # => opens the outside-the-tree check
neighbor not in in_tree # => True only if neighbor hasn't joined yet
): # => only edges reaching OUTSIDE the tree matter
heapq.heappush( # => the heap may end up holding stale entries too
heap, # => the shared candidate-edge priority queue
(w, v, neighbor), # => a new candidate frontier edge
) # => schedules this new candidate
return mst_edges, total_weight # => the MST's edges and its total weight
adjacency: dict[int, list[tuple[int, int]]] = { # => the SAME graph as Example 42
0: [(1, 2), (3, 6)], # => node 0's two outgoing edges -- the initial frontier
1: [ # => opens node 1's edge list
(0, 2), # => back to node 0
(2, 3), # => to node 2
(3, 8), # => to node 3, the priciest of node 1's edges
(4, 5), # => to node 4
], # => node 1's edges, including the priciest one
2: [(1, 3), (4, 7)], # => node 2's two edges
3: [ # => opens node 3's edge list
(0, 6), # => back to node 0
(1, 8), # => to node 1, tied for priciest overall
(4, 9), # => to node 4, the single priciest edge overall
], # => node 3's edges, including the two priciest overall
4: [(1, 5), (2, 7), (3, 9)], # => node 4's edges
} # => closes the adjacency map -- an undirected graph, each edge listed from both ends
mst_edges, total_weight = prim_mst(5, adjacency) # => builds the MST, starting from 0
print(len(mst_edges)) # => Output: 4
print(total_weight) # => Output: 16 -- matches Kruskal's answer on the same graph
assert len(mst_edges) == 4 # => confirms exactly n-1 edges
assert total_weight == 16 # => confirms the SAME minimum weight as Example 42's Kruskal
print("ex-43 OK") # => Output: ex-43 OKRun: python3 example.py
Output:
4
16
ex-43 OKlearning/code/ex-43-mst-prim/test_example.py
"""Example 43: pytest verification for Prim's MST."""
from example import prim_mst
def test_mst_total_weight_matches_kruskal_on_the_same_graph() -> None:
adjacency = {
0: [(1, 2), (3, 6)],
1: [(0, 2), (2, 3), (3, 8), (4, 5)],
2: [(1, 3), (4, 7)],
3: [(0, 6), (1, 8), (4, 9)],
4: [(1, 5), (2, 7), (3, 9)],
}
_, total = prim_mst(5, adjacency)
assert total == 16 # => the same minimum weight Example 42's Kruskal found
def test_mst_has_exactly_n_minus_1_edges() -> None:
adjacency = {0: [(1, 1), (2, 4)], 1: [(0, 1), (2, 2)], 2: [(0, 4), (1, 2)]}
mst_edges, _ = prim_mst(3, adjacency)
assert len(mst_edges) == 2
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Prim's and Kruskal's algorithms use completely different strategies -- growing one connected tree outward versus considering all edges globally by weight -- but both are provably optimal for MST, and both must arrive at the SAME total weight on any given graph (though not necessarily the identical set of edges, if ties exist).
Why it matters: Having two structurally different algorithms converge on the same answer is a strong correctness signal, and the choice between them in practice is about graph shape: Prim's heap-driven, Dijkstra-like approach (co-19) tends to be preferred for DENSE graphs, while Kruskal's sort-then-union-find approach tends to be preferred for SPARSE ones, since its cost is dominated by sorting the edge list.
Example 44: Max Non-Overlapping Intervals
ex-44 · exercises co-22
The greedy-choice property holds here: always pick the interval that finishes earliest among the remaining candidates. It never overlaps a previously chosen interval and leaves the most room for future picks. This example schedules a set of intervals greedily and confirms the count matches the true optimum.
learning/code/ex-44-greedy-interval-scheduling/example.py
"""Example 44: Max Non-Overlapping Intervals via Earliest-Finish-Time Greedy."""
# The greedy-choice property (co-22) holds here: always pick the interval
# that FINISHES earliest among the remaining candidates. It never overlaps a
# later-finishing choice's own start, and it leaves maximum room for the rest
# -- a proof-backed optimal strategy, unlike Example 45's failing greedy.
def max_non_overlapping_intervals( # => sort by finish time, then greedily take non-overlapping
intervals: list[tuple[int, int]], # => a list of (start, end) candidate intervals
) -> list[tuple[int, int]]: # => (start, end) pairs; returns the chosen subset
by_finish = sorted( # => opens the earliest-finish-first sort
intervals,
key=lambda iv: iv[1], # => sorts by the END field only
) # => O(n log n): earliest-finish first
chosen: list[tuple[int, int]] = [] # => the greedily selected, non-overlapping set
last_finish = float("-inf") # => nothing chosen yet -- any interval can start
for start, end in by_finish: # => O(n): one pass through finish-sorted intervals
if start >= last_finish: # => this interval starts AFTER the last chosen ended
chosen.append((start, end)) # => safe to take -- no overlap with `chosen`
last_finish = end # => this interval's end is now the new cutoff
return chosen # => the maximum-count set of mutually non-overlapping intervals
intervals: list[tuple[int, int]] = [ # => opens the classic CLRS activity-selection set
(1, 4), # => finishes 3rd-earliest -- a likely early pick
(3, 5), # => overlaps (1, 4) -- competes for the same early slot
(
0,
6,
), # => starts earliest but finishes late -- likely skipped in favor of shorter ones
(5, 7), # => finishes early enough to chain after (1, 4)
(3, 8), # => a long interval overlapping several others
(5, 9), # => overlaps (5, 7) -- competes for the same slot
(6, 10), # => overlaps (5, 7) -- starts before it finishes
(8, 11), # => starts right where (5, 7) ends -- a valid chain candidate
(8, 12), # => overlaps (8, 11) -- competes for the same slot
(2, 13), # => a very long interval, overlapping nearly everything
(12, 14), # => starts right where (8, 11) ends -- another valid chain candidate
] # => the classic CLRS activity-selection example set
chosen = max_non_overlapping_intervals(intervals) # => the greedy-optimal selection
print(chosen) # => Output: [(1, 4), (5, 7), (8, 11), (12, 14)]
print(len(chosen)) # => Output: 4
assert len(chosen) == 4 # => confirms the maximum possible count for this instance
for i in range(1, len(chosen)): # => confirms no two chosen intervals overlap
assert chosen[i][0] >= chosen[i - 1][1] # => next start is at/after previous finish
print("ex-44 OK") # => Output: ex-44 OKRun: python3 example.py
Output:
[(1, 4), (5, 7), (8, 11), (12, 14)]
4
ex-44 OKlearning/code/ex-44-greedy-interval-scheduling/test_example.py
"""Example 44: pytest verification for Greedy Interval Scheduling."""
from example import max_non_overlapping_intervals
def test_no_two_chosen_intervals_overlap() -> None:
intervals = [(1, 3), (2, 4), (3, 5), (0, 6), (5, 7)]
chosen = max_non_overlapping_intervals(intervals)
for i in range(1, len(chosen)):
assert chosen[i][0] >= chosen[i - 1][1]
def test_matches_the_known_optimal_count() -> None:
intervals = [(1, 2), (2, 3), (3, 4), (1, 4)] # => three tiny + one that blocks all
chosen = max_non_overlapping_intervals(intervals)
assert len(chosen) == 3 # => (1,2), (2,3), (3,4) -- the big one loses out
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Sorting by FINISH time (not start time, and not duration) is the specific choice that makes greedy interval scheduling provably optimal -- picking the interval that frees up the earliest 'next available' moment maximizes room for every future choice.
Why it matters: This is the canonical example of a correctly-applied greedy algorithm, worth contrasting directly against Example 45's greedy coin-change FAILURE: the difference is not 'greedy is good or bad' in general, it is whether a specific problem's structure actually guarantees the locally-optimal choice never costs anything globally. Interval scheduling's exchange-argument proof is the textbook demonstration that it does here.
Example 45: Greedy Coin Change Fails
ex-45 · exercises co-22, co-23
Greedy only works when the greedy-choice property actually holds. US coins {1, 5, 10, 25} happen to make greedy-always-optimal, but that is a property of those specific denominations, not of greedy in general. This example picks a non-canonical coin set and shows greedy producing a genuinely suboptimal answer.
learning/code/ex-45-greedy-coin-change-fails/example.py
"""Example 45: Greedy Coin Change Fails on a Non-Canonical Coin Set."""
# Greedy (co-22) only works when the greedy-choice property actually HOLDS.
# US coins {1, 5, 10, 25} happen to make greedy-always-optimal, but that's a
# property of THIS SPECIFIC coin set, not of "greedy" in general (co-23):
# {1, 3, 4} is a counterexample where always taking the largest coin loses.
def greedy_coin_change(
coins: list[int], amount: int
) -> list[int]: # => always takes the LARGEST coin that still fits
coins_sorted = sorted(coins, reverse=True) # => tries biggest coins first
used: list[int] = [] # => the coins greedily selected, in the order taken
remaining = amount # => how much of the target amount is still unpaid
for c in coins_sorted: # => tries each denomination, largest to smallest
while remaining >= c: # => keeps taking THIS coin as long as it still fits
used.append(c) # => records one more coin of this denomination
remaining -= c # => reduces the remaining amount accordingly
return used # => the greedy answer -- NOT guaranteed to be optimal
non_canonical_coins: list[int] = [1, 3, 4] # => the counterexample coin set
target = 6 # => the amount to make change for
greedy_answer = greedy_coin_change(non_canonical_coins, target) # => greedy's choice
print(greedy_answer) # => Output: [4, 1, 1]
print(len(greedy_answer)) # => Output: 3 -- greedy needs 3 coins
optimal_answer: list[int] = [3, 3] # => the TRUE optimum: two 3-coins, verified by hand
print(sum(optimal_answer) == target) # => Output: True
print(len(optimal_answer)) # => Output: 2 -- strictly fewer coins than greedy found
assert sum(greedy_answer) == target # => confirms greedy's answer is still VALID change
assert len(greedy_answer) == 3 # => confirms greedy's (suboptimal) coin count
assert len(optimal_answer) < len(
greedy_answer
) # => confirms a strictly BETTER answer exists that greedy never finds
print("ex-45 OK") # => Output: ex-45 OKRun: python3 example.py
Output:
[4, 1, 1]
3
True
2
ex-45 OKlearning/code/ex-45-greedy-coin-change-fails/test_example.py
"""Example 45: pytest verification for Greedy Coin Change's Failure Case."""
from example import greedy_coin_change
def test_greedy_produces_valid_but_suboptimal_change() -> None:
result = greedy_coin_change([1, 3, 4], 6)
assert sum(result) == 6 # => still valid change...
assert len(result) == 3 # => ...but not the minimum possible (2, via 3+3)
def test_greedy_is_optimal_on_us_coin_denominations() -> None:
result = greedy_coin_change([1, 5, 10, 25], 41)
assert sum(result) == 41
assert len(result) == 4 # => 25 + 10 + 5 + 1 -- greedy IS optimal for these coins
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Greedy's optimality is a property of the SPECIFIC problem instance's structure, not a general guarantee -- 'always take the largest coin that fits' produces the true minimum-coin answer for US currency, but produces a worse-than-optimal answer for a coin set like {1, 3, 4} that lacks the same structural guarantee.
Why it matters: This counterexample is the entire reason DP (co-23) exists as a separate paradigm from greedy: whenever a problem's greedy-choice property cannot be proven, exploring EVERY possibility (which DP does efficiently, via memoized subproblems) is the only way to guarantee an optimal answer. Example 48 solves this exact same coin set correctly with DP, beating greedy's wrong answer.
Example 46: Fibonacci -- Memoization vs Tabulation
ex-46 · exercises co-23
Both DP styles exploit overlapping subproblems: naive recursive fib(n) recomputes fib(5) exponentially many times. Memoization caches results top-down as they are computed; tabulation builds them bottom-up in a table. This example implements both and confirms they agree while both stay O(n).
learning/code/ex-46-dp-fib-memo-vs-tab/example.py
"""Example 46: Fibonacci -- Top-Down Memoization vs Bottom-Up Tabulation."""
# Both DP styles (co-23) exploit OVERLAPPING SUBPROBLEMS -- naive recursive
# fib(n) recomputes fib(5) exponentially many times. Memoization caches
# top-down recursive results; tabulation builds the answer bottom-up in a
# loop, with no recursion (and no call-stack depth risk) at all.
def fib_memo(n: int, cache: dict[int, int] | None = None) -> int: # => top-down, cached
if cache is None: # => the first call creates a fresh cache -- never share defaults
cache = {} # => a new, empty memo for this top-level call
if n <= 1: # => base cases: fib(0)=0, fib(1)=1
return n # => no recursion needed at the base
if n in cache: # => already computed -- an O(1) cache hit
return cache[n] # => reuses the previously computed result
cache[n] = fib_memo(n - 1, cache) + fib_memo(
n - 2, cache
) # => computes ONCE, caches
return cache[n] # => the newly computed and cached value
def fib_tab(n: int) -> int: # => bottom-up, iterative, no recursion at all
if n <= 1: # => base cases, same as above
return n
prev2, prev1 = 0, 1 # => fib(0) and fib(1), the two seeds tabulation builds from
for _ in range(2, n + 1): # => builds each fib(i) from the two before it
prev2, prev1 = prev1, prev2 + prev1 # => slides the window forward by one
return prev1 # => fib(n), built up with O(1) extra space, no call stack at all
values: list[int] = [0, 1, 5, 10, 20, 30] # => a spread of inputs, including base cases
memo_results = [fib_memo(n) for n in values] # => top-down answers
tab_results = [fib_tab(n) for n in values] # => bottom-up answers
print(memo_results) # => Output: [0, 1, 5, 55, 6765, 832040]
print(tab_results) # => Output: [0, 1, 5, 55, 6765, 832040]
assert memo_results == tab_results # => confirms both styles agree on every input
assert fib_tab(30) == 832040 # => confirms a specific known Fibonacci value
print("ex-46 OK") # => Output: ex-46 OKRun: python3 example.py
Output:
[0, 1, 5, 55, 6765, 832040]
[0, 1, 5, 55, 6765, 832040]
ex-46 OKlearning/code/ex-46-dp-fib-memo-vs-tab/test_example.py
"""Example 46: pytest verification for Memoized vs Tabulated Fibonacci."""
from example import fib_memo, fib_tab
def test_both_styles_agree_across_many_inputs() -> None:
for n in range(25):
assert fib_memo(n) == fib_tab(n)
def test_known_fibonacci_values() -> None:
assert fib_tab(0) == 0
assert fib_tab(1) == 1
assert fib_tab(10) == 55
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Memoization and tabulation solve the SAME overlapping-subproblems structure from opposite directions -- memoization caches results lazily as a top-down recursion needs them, while tabulation fills a table eagerly from the smallest subproblem upward -- and both eliminate the exponential recomputation naive recursion suffers.
Why it matters: This side-by-side comparison is the foundational DP lesson: 'dynamic programming' is not one specific coding pattern, it is the PRINCIPLE of never solving the same subproblem twice, implementable either way. Every later DP example in this topic (Examples 47-51, 59-62) picks whichever style fits its problem's dependency structure best.
Example 47: Count Ways to Climb Stairs
ex-47 · exercises co-23
ways(n) = ways(n-1) + ways(n-2): the last step taken to reach stair n was either a 1-step from stair n-1 or a 2-step from stair n-2, so the total ways to reach n is the sum of ways to reach each of those. This example computes stair counts via DP and checks them against the recurrence directly.
learning/code/ex-47-dp-climbing-stairs/example.py
"""Example 47: Count Ways to Climb n Stairs (1 or 2 Steps at a Time) via DP."""
# ways(n) = ways(n-1) + ways(n-2) (co-23): the LAST step taken to reach stair
# n was either a 1-step from stair n-1, or a 2-step from stair n-2 -- so the
# total ways to reach n is simply the sum of ways to reach each predecessor.
# This is structurally IDENTICAL to Fibonacci, just with different seed values.
def count_ways_to_climb( # => Fibonacci-shaped recurrence, computed bottom-up
n: int, # => the target stair count
) -> int: # => bottom-up tabulation, O(n) time, O(1) space
if n <= 1: # => 0 stairs: 1 way (do nothing); 1 stair: 1 way (a single 1-step)
return 1 # => base cases
prev2, prev1 = 1, 1 # => ways(0)=1, ways(1)=1 -- the two seeds
for _ in range(2, n + 1): # => builds ways(i) from the two steps before it
prev2, prev1 = ( # => opens the two-variable slide
prev1, # => the new prev2 -- what used to be prev1
prev2 + prev1, # => the new prev1 -- this stair's own way-count
) # => slides forward: ways(i) = ways(i-1)+ways(i-2)
return prev1 # => ways(n)
def count_ways_brute_force(n: int) -> int: # => O(2^n): enumerates every step sequence
if n <= 1: # => same base cases
return 1 # => 0 or 1 stairs: exactly one way
if n == 2: # => exactly 2 ways: [1,1] or [2]
return 2 # => the second base case
return count_ways_brute_force(
n - 1 # => recurses on the 1-step predecessor
) + count_ways_brute_force( # => the LAST-step split
n - 2 # => recurses on the 2-step predecessor
) # => no memoization -- deliberately re-derives the same recurrence, slowly
for n in [0, 1, 2, 3, 4, 5]: # => a spread of small n, where brute force stays feasible
fast = count_ways_to_climb(n) # => O(n) tabulated answer
slow = count_ways_brute_force(n) # => O(2^n) brute-force answer, as ground truth
print(f"n={n}: {fast}") # => Output: one "n=N: ways" line per n
assert fast == slow # => confirms both approaches agree exactly
assert count_ways_to_climb(5) == 8 # => 1+1+1+1+1, 2+1+1+1 (x4 orderings), 2+2+1 (x3)
print("ex-47 OK") # => Output: ex-47 OKRun: python3 example.py
Output:
n=0: 1
n=1: 1
n=2: 2
n=3: 3
n=4: 5
n=5: 8
ex-47 OKlearning/code/ex-47-dp-climbing-stairs/test_example.py
"""Example 47: pytest verification for Climbing Stairs DP."""
from example import count_ways_brute_force, count_ways_to_climb
def test_matches_brute_force_for_small_n() -> None:
for n in range(15):
assert count_ways_to_climb(n) == count_ways_brute_force(n)
def test_known_value_for_five_stairs() -> None:
assert count_ways_to_climb(5) == 8
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Recognizing that 'ways to reach stair n' decomposes into 'ways to reach n-1' plus 'ways to reach n-2' -- based on the LAST step taken, not the first -- is the same structural insight behind Fibonacci (Example 46), just relabeled for a different problem.
Why it matters: This example is a deliberate bridge from 'DP is Fibonacci' to 'DP is a general technique': recognizing the Fibonacci-shaped recurrence hiding inside an apparently unrelated counting problem is exactly the pattern-recognition skill that DP problems in interviews and real systems demand, far more often than a literal Fibonacci computation ever comes up.
Example 48: Minimum-Coin Change via DP
ex-48 · exercises co-23
dp[a] = minimum coins to make amount a: try every coin as the last one used and take the best option -- this explores possibilities greedy skipped entirely. This example solves Example 45's exact non-canonical coin set with DP and confirms it beats greedy's suboptimal answer.
learning/code/ex-48-dp-coin-change-min/example.py
"""Example 48: Minimum-Coin Change via DP -- Beats Example 45's Greedy Answer."""
# dp[a] = minimum coins to make amount a (co-23): try EVERY coin as the LAST
# one used, and take the best option -- this explores possibilities greedy
# never considers, so it can't be fooled the way Example 45's greedy was.
INF = float("inf") # => sentinel for "no way to make this amount (yet)"
def min_coins_dp( # => tries every coin as the LAST one used, keeps the best count
coins: list[int],
amount: int, # => the available coin denominations, and the target
) -> int | None: # => None if amount is unreachable with these coins
dp: list[float] = [0.0] + [ # => opens the dp array construction
INF # => every amount besides 0 starts as "not yet known reachable"
] * amount # => dp[0]=0 coins; everything else unknown
for a in range( # => opens the ascending-amount range
1,
amount + 1, # => builds every amount from 1 up to the target, in order
): # => builds dp[a] from smaller, already-solved amounts
for c in coins: # => tries EVERY coin as the one used LAST to reach amount a
if ( # => opens the "coin c improves dp[a]" check
c <= a and dp[a - c] + 1 < dp[a]
): # => using coin c beats the current best
dp[a] = ( # => opens the improved-count assignment
dp[a - c] + 1 # => one more coin on top of the best way to make a-c
) # => one more coin than however dp[a-c] was reached
return ( # => opens the final unreachable-or-int-result decision
None if dp[amount] == INF else int(dp[amount]) # => still INF means unreachable
) # => None if truly unreachable
non_canonical_coins: list[int] = [1, 3, 4] # => Example 45's counterexample coin set
target = 6 # => the same target Example 45 used
result = min_coins_dp(non_canonical_coins, target) # => the TRUE minimum via DP
print(result) # => Output: 2 -- two 3-coins: 3 + 3 = 6
assert result == 2 # => confirms DP finds the true optimum
assert result < 3 # => confirms DP strictly BEATS Example 45's greedy answer (3 coins)
assert min_coins_dp([1, 3, 4], 0) == 0 # => zero coins are needed to make amount 0
assert min_coins_dp([5], 3) is None # => 3 is unreachable using only 5-coins
print("ex-48 OK") # => Output: ex-48 OKRun: python3 example.py
Output:
2
ex-48 OKlearning/code/ex-48-dp-coin-change-min/test_example.py
"""Example 48: pytest verification for DP Minimum Coin Change."""
from example import min_coins_dp
def test_beats_the_greedy_answer_on_the_non_canonical_coin_set() -> None:
assert min_coins_dp([1, 3, 4], 6) == 2 # => strictly fewer than greedy's 3
def test_us_coins_matches_greedy_since_greedy_is_optimal_there() -> None:
assert min_coins_dp([1, 5, 10, 25], 41) == 4
def test_unreachable_amount_returns_none() -> None:
assert min_coins_dp([5], 3) is None
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: DP's 'try every last coin used' approach considers possibilities a greedy algorithm never revisits once it commits to a choice -- that exhaustive-but-efficient exploration (thanks to memoized overlapping subproblems) is exactly what recovers the TRUE optimum greedy missed.
Why it matters: This is the direct payoff of Example 45's cautionary tale: the same problem, solved with the right paradigm, produces the genuinely optimal answer instead of greedy's plausible-looking but wrong one. Recognizing 'greedy might not be provably correct here, fall back to DP' is a decision this topic asks you to make repeatedly, most explicitly in Example 58's side-by-side contrast.
Example 49: Levenshtein Edit Distance
ex-49 · exercises co-24
dp[i][j] = min edits to turn word1[:i] into word2[:j]: if the last characters match, no edit is needed there, reusing dp[i-1][j-1] directly; otherwise, the best of an insert, delete, or substitute. This example builds the full 2D table and checks the result against known string pairs.
learning/code/ex-49-dp-edit-distance/example.py
"""Example 49: Levenshtein Edit Distance -- a 2D DP Table."""
# dp[i][j] = min edits to turn word1[:i] into word2[:j] (co-24): if the last
# characters match, no edit is needed there -- reuse dp[i-1][j-1] directly.
# Otherwise, try all three edits (insert, delete, substitute) and take the
# cheapest, each reducing to a smaller, already-solved subproblem.
def edit_distance(word1: str, word2: str) -> int: # => O(m*n) time and space
m, n = len(word1), len(word2) # => the two words' lengths
dp: list[list[int]] = [ # => opens the 2D table construction
[0] * (n + 1)
for _ in range(m + 1) # => one fresh row of zeros per prefix of word1
] # => (m+1) x (n+1) table, one extra row/col for the empty-prefix case
for i in range(m + 1): # => turning word1[:i] into "" costs i deletions
dp[i][0] = i # => base case along the first column
for j in range(n + 1): # => turning "" into word2[:j] costs j insertions
dp[0][j] = j # => base case along the first row
for i in range(1, m + 1): # => fills the table row by row
for j in range(1, n + 1): # => and column by column within each row
if word1[i - 1] == word2[j - 1]: # => the last characters already match
dp[i][j] = dp[i - 1][ # => opens the diagonal-cell lookup
j - 1 # => the subproblem for both prefixes shortened by one char
] # => no edit needed here -- reuse the diagonal
else: # => the last characters differ -- try each of the three edits
dp[i][j] = 1 + min(
dp[i - 1][j], # => DELETE word1[i-1]
dp[i][j - 1], # => INSERT word2[j-1]
dp[i - 1][j - 1], # => SUBSTITUTE word1[i-1] for word2[j-1]
) # => plus 1 for whichever edit was cheapest
return dp[m][n] # => the bottom-right cell: the full-word edit distance
print(edit_distance("kitten", "sitting")) # => Output: 3 -- k->s, e->i, +g
print(edit_distance("", "abc")) # => Output: 3 -- three insertions
print(edit_distance("same", "same")) # => Output: 0 -- identical words need no edits
assert ( # => opens the classic-example check
edit_distance("kitten", "sitting")
== 3 # => True only if the computed distance is 3
) # => confirms the classic example's answer
assert edit_distance("", "abc") == 3 # => confirms the empty-string edge case
assert edit_distance("same", "same") == 0 # => confirms identical strings cost nothing
assert edit_distance("abc", "") == 3 # => confirms the mirrored empty-string case
print("ex-49 OK") # => Output: ex-49 OKRun: python3 example.py
Output:
3
3
0
ex-49 OKlearning/code/ex-49-dp-edit-distance/test_example.py
"""Example 49: pytest verification for Levenshtein Edit Distance."""
from example import edit_distance
def test_classic_kitten_to_sitting_example() -> None:
assert edit_distance("kitten", "sitting") == 3
def test_identical_strings_have_zero_distance() -> None:
assert edit_distance("hello", "hello") == 0
def test_completely_different_single_characters() -> None:
assert edit_distance("a", "b") == 1 # => one substitution
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: Edit distance's 2D table captures a problem with TWO shrinking dimensions simultaneously (a prefix of each string), which is exactly what distinguishes 2D DP from the single-dimension DP in Examples 46-48 -- each cell depends on up to three neighboring cells, not just one or two.
Why it matters: Edit distance is the algorithm behind spell-checkers, diff tools, and DNA sequence alignment -- anywhere 'how different are these two sequences' needs a precise, minimal-edit-count answer rather than a fuzzy guess. It is also the clearest on-ramp to 2D DP in this topic, since its three possible last-edit choices (insert, delete, substitute) map directly onto three neighboring table cells.
Example 50: Longest Common Subsequence
ex-50 · exercises co-24
dp[i][j] = LCS length of word1[:i] and word2[:j]: matching last characters extend the diagonal's LCS by one; otherwise, take the better of dropping a character from either string. This example builds the DP table, then reconstructs an actual longest common subsequence by walking the table backward.
learning/code/ex-50-dp-lcs/example.py
"""Example 50: Longest Common Subsequence -- DP Table, Then Reconstruction."""
# dp[i][j] = LCS length of word1[:i] and word2[:j] (co-24): matching last
# characters extend the diagonal's LCS by one; otherwise, take the better of
# dropping ONE character from either string. RECONSTRUCTION then walks the
# filled table backward, following exactly which choice produced each cell.
def lcs_length_table( # => builds the full DP table, bottom-up, one cell at a time
word1: str, # => the first string being compared
word2: str, # => the two strings to compare
) -> list[list[int]]: # => O(m*n) table build
m, n = len(word1), len(word2) # => the two strings' lengths
dp: list[list[int]] = [ # => opens the 2D table construction
[0] * (n + 1) # => one zero-filled row per prefix length of word1
for _ in range(m + 1) # => one fresh row of zeros per prefix of word1
] # => dp[i][0] and dp[0][j] are already 0 -- an empty prefix has LCS length 0
for i in range(1, m + 1): # => fills row by row
for j in range(1, n + 1): # => and column by column
if word1[i - 1] == word2[j - 1]: # => the last characters MATCH
dp[i][j] = dp[i - 1][j - 1] + 1 # => extends the diagonal's LCS by one
else: # => no match -- the LCS must drop ONE character from either string
dp[i][j] = max(
dp[i - 1][j], # => value if word1's last char is dropped
dp[i][j - 1], # => value if word2's last char is dropped
) # => takes whichever drop preserves the longer LCS
return dp # => the full table -- dp[m][n] is the final LCS length
def reconstruct_lcs( # => retraces which choice built each cell, to recover the actual chars
word1: str, # => the same first string used to build the table
word2: str, # => the same second string used to build the table
dp: list[list[int]], # => the original strings + filled table
) -> str: # => walks the table BACKWARD from (m, n)
i, j = len(word1), len(word2) # => starts at the bottom-right cell
chars: list[str] = [] # => accumulates matched characters, in REVERSE order
while i > 0 and j > 0: # => stops once either string is exhausted
if ( # => opens the match check
word1[i - 1] == word2[j - 1]
): # => this position was a MATCH -- part of the LCS
chars.append(word1[i - 1]) # => records this matched character
i -= 1 # => moves diagonally, retracing the match
j -= 1 # => both indices step back together on a diagonal match
elif dp[i - 1][j] >= dp[i][j - 1]: # => the LCS came from dropping word1's char
i -= 1 # => moves up, following that earlier decision
else: # => the LCS came from dropping word2's char instead
j -= 1 # => moves left, following THAT earlier decision
return "".join(reversed(chars)) # => reverses back to forward reading order
# demonstrates the full pipeline: build the table, then reconstruct from it
word1, word2 = "ABCBDAB", "BDCABA" # => the classic LCS example pair
table = lcs_length_table(word1, word2) # => builds the DP table once
length = table[len(word1)][len(word2)] # => the LCS length, read from the final cell
sequence = reconstruct_lcs(word1, word2, table) # => the actual matched subsequence
print(length) # => Output: 4
print(sequence) # => Output: BCBA
assert length == 4 # => confirms the known LCS length for this classic example
assert len(sequence) == length # => confirms the reconstructed string has that length
assert sequence == "BCBA" # => confirms the exact reconstructed subsequence
print("ex-50 OK") # => Output: ex-50 OKRun: python3 example.py
Output:
4
BCBA
ex-50 OKlearning/code/ex-50-dp-lcs/test_example.py
"""Example 50: pytest verification for Longest Common Subsequence."""
from example import lcs_length_table, reconstruct_lcs
def test_classic_abcbdab_bdcaba_example() -> None:
word1, word2 = "ABCBDAB", "BDCABA"
table = lcs_length_table(word1, word2)
assert table[len(word1)][len(word2)] == 4
def test_no_common_characters_has_zero_length_lcs() -> None:
table = lcs_length_table("abc", "xyz")
assert table[3][3] == 0
assert reconstruct_lcs("abc", "xyz", table) == ""
def test_reconstructed_sequence_is_a_valid_subsequence_of_both_strings() -> None:
word1, word2 = "hello", "yellow"
table = lcs_length_table(word1, word2)
sequence = reconstruct_lcs(word1, word2, table)
assert len(sequence) == table[len(word1)][len(word2)]
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: A DP table stores enough information not just to compute the ANSWER (the LCS length), but to RECONSTRUCT an actual optimal solution afterward, by walking backward through the table and following whichever transition produced each cell's value.
Why it matters: Many DP examples in this topic stop at computing a single number (the length, the minimum cost); this example demonstrates the extra step -- reconstruction -- that turns 'the answer is 4' into 'the answer is 4, and here is an actual sequence achieving it,' which is what a real diff tool or version-control merge needs, not just a length.
Example 51: 0/1 Knapsack
ex-51 · exercises co-24
dp[i][w] = best value using the first i items within capacity w: for each item, either skip it (dp[i-1][w]) or take it (its value plus dp[i-1][w - weight]), and keep the better option. This example solves a small knapsack instance with the full 2D table and confirms the optimal value.
learning/code/ex-51-dp-knapsack-01/example.py
"""Example 51: 0/1 Knapsack -- 2D DP over Items and Capacity."""
# dp[i][w] = best value using the first i items within capacity w (co-24):
# for each item, either SKIP it (dp[i-1][w]) or TAKE it (its value plus
# dp[i-1][w-weight], if it fits) -- "0/1" because each item is taken at
# most once, unlike the unbounded knapsack variant.
def knapsack_01( # => for each item, either SKIP it or TAKE it, whichever is better
weights: list[int], # => each item's own weight
values: list[int], # => each item's own value
capacity: int, # => item weights/values + limit
) -> int: # => O(n * capacity) time and space
n = len(weights) # => number of available items
dp: list[list[int]] = [ # => opens the 2D table construction
[0] * (capacity + 1) # => one zero-filled row per item count
for _ in range(n + 1) # => one fresh row of zeros per item count
] # => dp[0][*] = 0: zero items always yields zero value
for i in range(1, n + 1): # => considers items one at a time
weight, value = weights[i - 1], values[i - 1] # => this item's own weight/value
for w in range(capacity + 1): # => every possible capacity, from 0 up
dp[i][w] = dp[i - 1][w] # => the SKIP option: value stays whatever it was
if weight <= w: # => the TAKE option is only possible if it actually fits
dp[i][w] = max(
dp[i][w], # => the SKIP option's value
value + dp[i - 1][w - weight], # => the TAKE option's value
) # => best of skip vs take
return dp[n][capacity] # => the best achievable value at full capacity
weights: list[int] = [2, 3, 4, 5] # => four items' weights
values: list[int] = [3, 4, 5, 6] # => their corresponding values
capacity = 5 # => the knapsack's weight limit
best_value = knapsack_01(weights, values, capacity) # => the optimal achievable value
print(best_value) # => Output: 7 -- items 0 and 1 (weight 2+3=5, value 3+4=7)
assert best_value == 7 # => confirms the known optimal value for this instance
assert knapsack_01([], [], 10) == 0 # => no items at all -- nothing to gain
assert ( # => opens the too-heavy-item check
knapsack_01([100], [50], 1) == 0 # => True only if the DP correctly skips it
) # => an item too heavy to ever fit contributes nothing
print("ex-51 OK") # => Output: ex-51 OKRun: python3 example.py
Output:
7
ex-51 OKlearning/code/ex-51-dp-knapsack-01/test_example.py
"""Example 51: pytest verification for 0/1 Knapsack."""
from example import knapsack_01
def test_matches_a_known_optimal_value() -> None:
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
assert knapsack_01(weights, values, capacity=7) == 9 # => items of weight 3 and 4
def test_zero_capacity_yields_zero_value() -> None:
assert knapsack_01([1, 2, 3], [10, 20, 30], capacity=0) == 0
def test_single_item_that_exactly_fits() -> None:
assert knapsack_01([5], [42], capacity=5) == 42
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: 0/1 knapsack's 'skip or take' choice at every item, tracked against every possible remaining capacity, is what the 2D table (items x capacity) is built to capture -- a decision GREEDY (picking the best value-per-weight ratio) cannot make correctly, as Example 79's shootout demonstrates directly.
Why it matters: 0/1 knapsack is the most commonly cited DP problem in technical interviews for good reason: it has a genuinely deceptive greedy-looking shortcut (sort by value/weight ratio) that FAILS, and the 2D table here is what correctly explores every skip-or-take combination without brute-forcing all 2^n subsets.
Example 52: Binary Search Boundaries
ex-52 · exercises co-27
A plain binary search stops at any match. Finding the leftmost match instead means: on a match, do not stop, keep searching the left half for an even earlier occurrence. This example finds both leftmost and rightmost occurrences of a repeated value, including cases where the target is entirely absent.
learning/code/ex-52-binary-search-boundary/example.py
"""Example 52: Binary Search for the Leftmost and Rightmost Occurrence."""
# A plain binary search stops at ANY match. Finding the LEFTMOST (co-27)
# match instead means: on a match, don't stop -- keep searching the LEFT
# half for an even earlier one. The rightmost search is the mirror image.
def leftmost_index(items: list[int], target: int) -> int: # => -1 if target is absent
lo, hi = 0, len(items) - 1 # => the active search range
result = -1 # => no match found yet
while lo <= hi: # => standard binary search bounds
mid = (lo + hi) // 2 # => the midpoint of the active range
if items[mid] == target: # => found A match -- but is it the FIRST one?
result = mid # => records this as the best-known leftmost match so far
hi = mid - 1 # => keeps searching LEFT for an even earlier occurrence
elif items[mid] < target: # => target must be further right
lo = mid + 1 # => shrinks the range from the left edge
else: # => target must be further left
hi = mid - 1 # => shrinks the range from the right edge
return result # => the smallest index where target occurs, or -1
def rightmost_index(items: list[int], target: int) -> int: # => -1 if target is absent
lo, hi = 0, len(items) - 1 # => the active search range
result = -1 # => no match found yet
while lo <= hi: # => standard binary search bounds
mid = (lo + hi) // 2 # => the midpoint of the active range
if items[mid] == target: # => found A match -- but is it the LAST one?
result = mid # => records this as the best-known rightmost match so far
lo = mid + 1 # => keeps searching RIGHT for an even later occurrence
elif ( # => opens the rightmost-side range-narrowing check
items[mid] < target
): # => same rule as leftmost -- target lies further right
lo = mid + 1 # => shrinks the range from the left edge
else: # => same rule as leftmost -- target lies further left
hi = mid - 1 # => shrinks the range from the right edge
return result # => the largest index where target occurs, or -1
data: list[int] = [1, 2, 2, 2, 3, 4, 4, 5] # => sorted, with runs of duplicate values
print(leftmost_index(data, 2)) # => Output: 1 -- the first of three 2's
print(rightmost_index(data, 2)) # => Output: 3 -- the last of three 2's
print(leftmost_index(data, 4)) # => Output: 5
print(leftmost_index(data, 9)) # => Output: -1 -- 9 is absent entirely
assert leftmost_index(data, 2) == 1 # => confirms the first occurrence's index
assert rightmost_index(data, 2) == 3 # => confirms the last occurrence's index
assert leftmost_index(data, 9) == -1 # => confirms an absent target returns -1
assert rightmost_index(data, 9) == -1 # => confirms the mirrored absent case too
assert leftmost_index(data, 1) == rightmost_index( # => a single-occurrence value
data, # => the same sorted array searched throughout
1, # => the value 1, which appears exactly once in data
) # => a value with only ONE occurrence has matching leftmost and rightmost
print("ex-52 OK") # => Output: ex-52 OKRun: python3 example.py
Output:
1
3
5
-1
ex-52 OKlearning/code/ex-52-binary-search-boundary/test_example.py
"""Example 52: pytest verification for Binary Search Boundaries."""
from example import leftmost_index, rightmost_index
def test_leftmost_and_rightmost_bracket_a_run_of_duplicates() -> None:
data = [1, 3, 3, 3, 3, 7, 9]
assert leftmost_index(data, 3) == 1
assert rightmost_index(data, 3) == 4
def test_absent_target_returns_negative_one_both_ways() -> None:
data = [2, 4, 6, 8]
assert leftmost_index(data, 5) == -1
assert rightmost_index(data, 5) == -1
def test_single_occurrence_matches_on_both_boundaries() -> None:
data = [10, 20, 30]
assert leftmost_index(data, 20) == rightmost_index(data, 20) == 1
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: Finding a BOUNDARY (leftmost or rightmost occurrence) with binary search requires changing what happens ON a match -- instead of stopping immediately, keep narrowing toward the boundary while remembering the best match found so far.
Why it matters: This small variation on binary search is the foundation for Example 73's binary-search-on-a-monotonic-predicate: both techniques rely on the same idea of narrowing toward a BOUNDARY rather than stopping at the first success, which generalizes far beyond searching a literal sorted array into searching any monotonic answer space.
← Previous: Beginner Examples · Next: Advanced Examples →
Last updated July 13, 2026