Practice

Python exercises

Make a change, predict what it will do, and compare the result.

  1. 1

    Python Variables

    Store and reuse values with clear names.

    Adapt the lesson example. Change one value or element, predict the result, and explain why it changes.

    Show starting example
    Python
    name = "Ada"
    score = 91
    print(name, score)
  2. 2

    Python arithmetic and operators

    Calculate totals and distinguish division from floor division.

    Predict 17 // 5 and 17 % 5 before running them.

    Reveal answer and reasoning

    The quotient is 3 and the remainder is 2: 17 = 3 * 5 + 2.

    Show starting example
    Python
    price = 12
    quantity = 3
    total = price * quantity
    print(total)
    print(7 / 2, 7 // 2, 7 % 2)
    assert -7 // 2 == -4
  3. 3

    Python Lists

    Keep ordered values together.

    Adapt the lesson example. Change one value or element, predict the result, and explain why it changes.

    Show starting example
    Python
    scores = [82, 91, 76]
    scores.append(95)
    for score in scores:
        print(score)
  4. 4

    Python Functions

    Package reusable behavior.

    Adapt the lesson example. Change one value or element, predict the result, and explain why it changes.

    Show starting example
    Python
    def average(values):
        return sum(values) / len(values)
    
    print(average([82, 91, 76, 95]))
  5. 5

    Python decisions and boundaries

    Choose one branch with if, elif, and else.

    Add a valid range check for scores from 0 through 100.

    Reveal answer and reasoning

    Before the grade branches, reject score < 0 or score > 100. Check -1, 0, 100, and 101.

    Show starting example
    Python
    def grade(score):
        if score >= 80:
            return "Distinction"
        elif score >= 50:
            return "Pass"
        else:
            return "Retry"
    
    print(grade(80))
    print(grade(49))
    assert grade(50) == "Pass"
  6. 6

    Python loops and accumulators

    Build a total one iteration at a time.

    Adapt the function to count only positive values in [-2, 0, 3, 5].

    Reveal answer and reasoning

    Start count at 0 and increment only when value > 0. The answer is 2.

    Show starting example
    Python
    def total_of(values):
        total = 0
        for value in values:
            total += value
        return total
    
    print(total_of([2, 4, 6]))
    assert total_of([]) == 0
    assert total_of([-2, 2]) == 0
  7. 7

    Python while loops and digit processing

    Repeat until a changing condition becomes false.

    Trace digit_sum(1002).

    Reveal answer and reasoning

    The extracted digits are 2, 0, 0, 1 and the result is 3.

    Show starting example
    Python
    def digit_sum(number):
        number = abs(number)
        total = 0
        while number > 0:
            total += number % 10
            number //= 10
        return total
    
    print(digit_sum(204))
    assert digit_sum(0) == 0
    assert digit_sum(-204) == 6
  8. 8

    Python nested loops and matrices

    Visit every cell of a rectangular matrix.

    Predict the row totals after adding [7, 8, 9].

    Reveal answer and reasoning

    The output becomes 6, 15, and 24 on separate lines.

    Show starting example
    Python
    matrix = [[1, 2, 3], [4, 5, 6]]
    for row in matrix:
        row_total = 0
        for value in row:
            row_total += value
        print(row_total)
  9. 9

    Python strings and palindrome checks

    Normalize text before comparing characters.

    Predict the results for 'Level' and 'hello'.

    Reveal answer and reasoning

    Level gives True after lowercasing; hello gives False.

    Show starting example
    Python
    def is_palindrome(text):
        cleaned = text.lower().replace(" ", "")
        return cleaned == cleaned[::-1]
    
    print(is_palindrome("Never odd or even"))
    print(is_palindrome("Python"))
    assert is_palindrome("") is True
  10. 10

    Python conversion and input validation

    Convert text safely and handle invalid input.

    Add an upper bound of 130.

    Reveal answer and reasoning

    Reject age > 130 with a separate message. Test 130 as accepted and 131 as rejected.

    Show starting example
    Python
    def parse_age(text):
        try:
            age = int(text)
        except ValueError:
            return "Invalid integer"
        if age < 0:
            return "Age cannot be negative"
        return str(age)
    
    print(parse_age("21"))
    print(parse_age("abc"))
    print(parse_age("-1"))
    assert parse_age("0") == "0"
  11. 11

    Python recursion and factorial

    Separate a base case from a smaller recursive problem.

    Draw the call stack for factorial(3).

    Reveal answer and reasoning

    Calls descend 3 -> 2 -> 1 -> 0, then return 1 -> 1 -> 2 -> 6.

    Show starting example
    Python
    def factorial(n):
        if n < 0:
            raise ValueError("n must be nonnegative")
        if n == 0:
            return 1
        return n * factorial(n - 1)
    
    print(factorial(4))
    assert factorial(0) == 1
    assert factorial(1) == 1
  12. 12

    Python files and context managers

    Write and read a UTF-8 text file with automatic cleanup.

    Append a third line 'Review' to the text and predict the count.

    Reveal answer and reasoning

    The count becomes 3; lines[1] remains Practice.

    Show starting example
    Python
    from pathlib import Path
    from tempfile import TemporaryDirectory
    
    with TemporaryDirectory() as folder:
        path = Path(folder) / "notes.txt"
        path.write_text("Learn\nPractice\n", encoding="utf-8")
        with path.open(encoding="utf-8") as handle:
            lines = [line.strip() for line in handle]
        print(len(lines))
        print(lines[1])
  13. 13

    Python classes and objects

    Give each object its own state and methods.

    Increment second once, then print both values.

    Reveal answer and reasoning

    The result is 2 1 because each object keeps its own value.

    Show starting example
    Python
    class Counter:
        def __init__(self):
            self.value = 0
        def increment(self):
            self.value += 1
    
    first = Counter()
    second = Counter()
    first.increment()
    first.increment()
    print(first.value, second.value)
    assert second.value == 0
  14. 14

    Python encapsulation and properties

    Keep validation next to the state it protects.

    Try a deposit of -5 and then check the balance.

    Reveal answer and reasoning

    It raises ValueError and the balance remains 25.

    Show starting example
    Python
    class Account:
        def __init__(self):
            self._balance = 0
        @property
        def balance(self):
            return self._balance
        def deposit(self, amount):
            if amount <= 0:
                raise ValueError("deposit must be positive")
            self._balance += amount
    
    account = Account()
    account.deposit(25)
    print(account.balance)
    try:
        account.deposit(0)
    except ValueError:
        print("Rejected")
    assert account.balance == 25
  15. 15

    Python inheritance and polymorphism

    Call overridden behavior through a common interface.

    Add Bird with a speak method returning chirp.

    Reveal answer and reasoning

    Include Bird() in the list. The third line becomes chirp; the loop does not need to change.

    Show starting example
    Python
    class Animal:
        def speak(self):
            return "sound"
    
    class Dog(Animal):
        def speak(self):
            return "woof"
    
    class Cat(Animal):
        def speak(self):
            return "meow"
    
    for animal in [Dog(), Cat()]:
        print(animal.speak())
  16. 16

    Python abstraction with abstract classes

    Specify required behavior while leaving implementation to subclasses.

    Implement Square with one side and an area method.

    Reveal answer and reasoning

    Store side in __init__, return side * side, and check that Square(5).area() is 25.

    Show starting example
    Python
    from abc import ABC, abstractmethod
    
    class Shape(ABC):
        @abstractmethod
        def area(self):
            pass
    
    class Rectangle(Shape):
        def __init__(self, width, height):
            self.width = width
            self.height = height
        def area(self):
            return self.width * self.height
    
    shape = Rectangle(3, 4)
    print(shape.area())
    assert Rectangle(0, 4).area() == 0
  17. 17

    Python composition and object relationships

    Build behavior by containing another object.

    Replace Engine with a compatible ElectricMotor returning Motor ready.

    Reveal answer and reasoning

    Pass ElectricMotor() to Car. The result is Motor ready; car ready without changing Car.

    Show starting example
    Python
    class Engine:
        def start(self):
            return "Engine ready"
    
    class Car:
        def __init__(self, engine):
            self.engine = engine
        def start(self):
            return self.engine.start() + "; car ready"
    
    car = Car(Engine())
    print(car.start())
  18. 18

    Python algorithm complexity

    Compare linear work with quadratic work by counting operations.

    Predict work(10).

    Reveal answer and reasoning

    It returns (10, 100). Increasing to 20 gives (20, 400).

    Show starting example
    Python
    def work(n):
        linear = 0
        quadratic = 0
        for _ in range(n):
            linear += 1
        for _ in range(n):
            for _ in range(n):
                quadratic += 1
        return linear, quadratic
    
    print(work(3))
    print(work(6))
    assert work(0) == (0, 0)
  19. 19

    Python sets and tuples

    Choose uniqueness or immutable grouping to fit the problem.

    Calculate sorted(first - second).

    Reveal answer and reasoning

    The result is [1, 2], the elements only in first.

    Show starting example
    Python
    first = {1, 2, 3}
    second = {3, 4}
    print(sorted(first & second))
    print(sorted(first | second))
    point = (2, 5)
    x, y = point
    print(x + y)
    assert len({1, 1, 2}) == 2
  20. 20

    Python linear search

    Find the first matching element without assuming sorted input.

    Find 2 in [4, 9, 2, 9].

    Reveal answer and reasoning

    The return value is index 2 after three comparisons.

    Show starting example
    Python
    def find(values, target):
        for index, value in enumerate(values):
            if value == target:
                return index
        return -1
    
    print(find([4, 9, 2, 9], 9))
    print(find([4, 9, 2, 9], 7))
    assert find([], 9) == -1
    assert find([9], 9) == 0
  21. 21

    Python binary search

    Halve a sorted search interval after each comparison.

    Trace a search for 2 in the same list.

    Reveal answer and reasoning

    Inspect indices 2 then 0 and return 0.

    Show starting example
    Python
    def binary_search(values, target):
        low, high = 0, len(values) - 1
        while low <= high:
            mid = low + (high - low) // 2
            if values[mid] == target:
                return mid
            if values[mid] < target:
                low = mid + 1
            else:
                high = mid - 1
        return -1
    
    print(binary_search([2, 4, 6, 8, 10], 8))
    print(binary_search([2, 4, 6, 8, 10], 7))
    assert binary_search([], 1) == -1
    assert binary_search([2], 2) == 0
  22. 22

    Python bubble sort

    Move the largest remaining item to the end with adjacent swaps.

    Trace [1, 2, 3].

    Reveal answer and reasoning

    The first pass makes no swaps and exits early, returning the unchanged order.

    Show starting example
    Python
    def bubble_sort(values):
        result = values.copy()
        for end in range(len(result) - 1, 0, -1):
            swapped = False
            for i in range(end):
                if result[i] > result[i + 1]:
                    result[i], result[i + 1] = result[i + 1], result[i]
                    swapped = True
            if not swapped:
                break
        return result
    
    print(bubble_sort([3, 2, 1]))
    assert bubble_sort([]) == []
    assert bubble_sort([1, 1, 2]) == [1, 1, 2]
  23. 23

    Python selection sort

    Select the smallest remaining value for each position.

    How many comparisons are made for four values?

    Reveal answer and reasoning

    The suffix scans make 3 + 2 + 1 = 6 comparisons.

    Show starting example
    Python
    def selection_sort(values):
        result = values.copy()
        for i in range(len(result)):
            smallest = i
            for j in range(i + 1, len(result)):
                if result[j] < result[smallest]:
                    smallest = j
            result[i], result[smallest] = result[smallest], result[i]
        return result
    
    print(selection_sort([3, 1, 2]))
    assert selection_sort([]) == []
    assert selection_sort([2, 2, 1]) == [1, 2, 2]
  24. 24

    Python insertion sort

    Grow a sorted prefix by shifting larger elements.

    Trace [2, 1, 1].

    Reveal answer and reasoning

    After pass one: [1, 2, 1]. After pass two: [1, 1, 2]. Equal ones keep their original relative order.

    Show starting example
    Python
    def insertion_sort(values):
        result = values.copy()
        for i in range(1, len(result)):
            key = result[i]
            j = i - 1
            while j >= 0 and result[j] > key:
                result[j + 1] = result[j]
                j -= 1
            result[j + 1] = key
        return result
    
    print(insertion_sort([3, 1, 2]))
    assert insertion_sort([]) == []
    assert insertion_sort([2, 2, 1]) == [1, 2, 2]
  25. 25

    Python merge sort

    Sort halves recursively and merge them in order.

    Predict merge_sort([3, 3, -1]).

    Reveal answer and reasoning

    The result is [-1, 3, 3]; duplicates are preserved.

    Show starting example
    Python
    def merge_sort(values):
        if len(values) <= 1:
            return values.copy()
        middle = len(values) // 2
        left = merge_sort(values[:middle])
        right = merge_sort(values[middle:])
        merged = []
        i = j = 0
        while i < len(left) and j < len(right):
            if left[i] <= right[j]:
                merged.append(left[i])
                i += 1
            else:
                merged.append(right[j])
                j += 1
        return merged + left[i:] + right[j:]
    
    print(merge_sort([4, 1, 3, 2]))
    assert merge_sort([]) == []
    assert merge_sort([2, 1, 2]) == [1, 2, 2]
  26. 26

    Python quicksort and partitioning

    Partition around a pivot, then sort smaller subproblems.

    Predict the partition groups for [4, 2, 4].

    Reveal answer and reasoning

    The pivot is 2: lower=[], equal=[2], upper=[4,4], producing [2,4,4].

    Show starting example
    Python
    def quick_sort(values):
        if len(values) <= 1:
            return values.copy()
        pivot = values[len(values) // 2]
        lower = [value for value in values if value < pivot]
        equal = [value for value in values if value == pivot]
        upper = [value for value in values if value > pivot]
        return quick_sort(lower) + equal + quick_sort(upper)
    
    print(quick_sort([3, 1, 2, 1]))
    assert quick_sort([]) == []
    assert quick_sort([2, 2]) == [2, 2]
  27. 27

    Python stacks and balanced parentheses

    Use last-in, first-out storage to match nested parentheses.

    Trace balanced('(()())').

    Reveal answer and reasoning

    The stack depths are 1, 2, 1, 2, 1, 0, so the result is True.

    Show starting example
    Python
    def balanced(text):
        stack = []
        for char in text:
            if char == "(":
                stack.append(char)
            elif char == ")":
                if not stack:
                    return False
                stack.pop()
        return not stack
    
    print(balanced("(())"))
    print(balanced(")("))
    assert balanced("")
    assert not balanced("(()")
  28. 28

    Python queues with deque

    Process work in first-in, first-out order.

    Append D before the final removal. What remains after removing C?

    Reveal answer and reasoning

    The queue contains D, so the final emptiness assertion would need to change.

    Show starting example
    Python
    from collections import deque
    
    queue = deque(["A", "B"])
    queue.append("C")
    print(queue.popleft())
    print(queue.popleft())
    print(list(queue))
    assert queue.popleft() == "C"
    assert not queue
  29. 29

    Python dictionaries and frequency counting

    Count repeated values using key-value lookup.

    Count 'red red blue green'.

    Reveal answer and reasoning

    Sorted output is blue 1, green 1, red 2 on separate lines.

    Show starting example
    Python
    counts = {}
    for word in "red blue red".split():
        counts[word] = counts.get(word, 0) + 1
    for word in sorted(counts):
        print(word, counts[word])
    assert counts.get("green", 0) == 0
  30. 30

    Python singly linked lists

    Follow node references and insert at the head.

    Prepend 0 and predict the traversal.

    Reveal answer and reasoning

    The chain becomes 0 -> 1 -> 2 -> 3 -> None and prints [0, 1, 2, 3].

    Show starting example
    Python
    class Node:
        def __init__(self, value, next_node=None):
            self.value = value
            self.next = next_node
    
    head = Node(2, Node(3))
    head = Node(1, head)
    current = head
    values = []
    while current is not None:
        values.append(current.value)
        current = current.next
    print(values)
    assert values == [1, 2, 3]
  31. 31

    Python binary search trees

    Insert ordered keys and traverse them in sorted order.

    Insert 5 and 4 after the existing values.

    Reveal answer and reasoning

    5 becomes the left child of 6; duplicate 4 is ignored. Inorder becomes [1, 2, 3, 4, 5, 6].

    Show starting example
    Python
    class Node:
        def __init__(self, value):
            self.value = value
            self.left = self.right = None
    
    def insert(root, value):
        if root is None:
            return Node(value)
        if value < root.value:
            root.left = insert(root.left, value)
        elif value > root.value:
            root.right = insert(root.right, value)
        return root
    
    def inorder(root, values):
        if root is not None:
            inorder(root.left, values)
            values.append(root.value)
            inorder(root.right, values)
    
    root = None
    for value in [4, 2, 6, 1, 3]:
        root = insert(root, value)
    values = []
    inorder(root, values)
    print(values)
    assert values == [1, 2, 3, 4, 6]
  32. 32

    Python heaps and priority queues

    Remove the smallest priority efficiently.

    Add (0, 'urgent') before heapify.

    Reveal answer and reasoning

    urgent is printed first because 0 is the smallest priority.

    Show starting example
    Python
    import heapq
    
    tasks = [(3, "backup"), (1, "alert"), (2, "email")]
    heapq.heapify(tasks)
    while tasks:
        priority, name = heapq.heappop(tasks)
        print(priority, name)
    assert tasks == []
  33. 33

    Python graphs and breadth-first search

    Visit reachable vertices level by level without revisiting cycles.

    Add a disconnected vertex E. Will BFS from A visit it?

    Reveal answer and reasoning

    No. Run a separate search from E or iterate over all unvisited vertices to cover every component.

    Show starting example
    Python
    from collections import deque
    
    def bfs(graph, start):
        seen = {start}
        queue = deque([start])
        order = []
        while queue:
            node = queue.popleft()
            order.append(node)
            for neighbor in graph.get(node, []):
                if neighbor not in seen:
                    seen.add(neighbor)
                    queue.append(neighbor)
        return order
    
    graph = {"A": ["B", "C"], "B": ["A", "D"], "C": ["D"], "D": []}
    print(bfs(graph, "A"))
    assert bfs({}, "Z") == ["Z"]
  34. 34

    Python depth-first search

    Explore one branch fully before returning to alternatives.

    Reverse A's neighbor order to [C, B].

    Reveal answer and reasoning

    The DFS order becomes A, C, D, B for this graph.

    Show starting example
    Python
    def dfs(graph, start):
        seen = set()
        order = []
        def visit(node):
            if node in seen:
                return
            seen.add(node)
            order.append(node)
            for neighbor in graph.get(node, []):
                visit(neighbor)
        visit(start)
        return order
    
    graph = {"A": ["B", "C"], "B": ["D"], "C": ["D"], "D": ["A"]}
    print(dfs(graph, "A"))
    assert dfs({}, "Z") == ["Z"]
  35. 35

    Python Dijkstra shortest paths

    Relax nonnegative weighted edges using a priority queue.

    Change edge A-B from 4 to 1.

    Reveal answer and reasoning

    B's shortest distance becomes 1; the route through C costs 3 and does not improve it.

    Show starting example
    Python
    import heapq
    
    def shortest_paths(graph, start):
        distances = {start: 0}
        heap = [(0, start)]
        while heap:
            distance, node = heapq.heappop(heap)
            if distance != distances[node]:
                continue
            for neighbor, weight in graph.get(node, []):
                if weight < 0:
                    raise ValueError("nonnegative weights required")
                candidate = distance + weight
                if candidate < distances.get(neighbor, float("inf")):
                    distances[neighbor] = candidate
                    heapq.heappush(heap, (candidate, neighbor))
        return distances
    
    graph = {"A": [("B", 4), ("C", 1)], "C": [("B", 2)], "B": []}
    result = shortest_paths(graph, "A")
    print(sorted(result.items()))
    assert shortest_paths({}, "A") == {"A": 0}
  36. 36

    Python dynamic programming

    Reuse earlier results instead of recomputing overlapping subproblems.

    Predict fibonacci(7) and fibonacci(8).

    Reveal answer and reasoning

    They are 13 and 21. Each uses the sum of the previous two results.

    Show starting example
    Python
    def fibonacci(n):
        if n < 0:
            raise ValueError("n must be nonnegative")
        a, b = 0, 1
        for _ in range(n):
            a, b = b, a + b
        return a
    
    print(fibonacci(6))
    assert fibonacci(0) == 0
    assert fibonacci(1) == 1
  37. 37

    Python backtracking and subsets

    Choose, recurse, and undo to explore a decision tree.

    How many subsets are produced for three distinct values?

    Reveal answer and reasoning

    There are 2^3 = 8, including the empty subset and the full set.

    Show starting example
    Python
    def subsets(values):
        result = []
        path = []
        def visit(index):
            if index == len(values):
                result.append(path.copy())
                return
            visit(index + 1)
            path.append(values[index])
            visit(index + 1)
            path.pop()
        visit(0)
        return result
    
    print(subsets([1, 2]))
    assert subsets([]) == [[]]
  38. 38

    Growth rates and asymptotic notation

    Count operations to see how logarithmic, linear, and quadratic work grow with input size.

    Predict count_log(64), count_linear(64), and count_quadratic(64).

    Reveal answer and reasoning

    6, 64, and 4096. 64 is 2⁶, so six halvings reach 1; the other counts are n and n².

    Show starting example
    Python
    def count_log(n):
        steps = 0
        while n > 1:
            n //= 2
            steps += 1
        return steps
    
    
    def count_linear(n):
        steps = 0
        for _ in range(n):
            steps += 1
        return steps
    
    
    def count_quadratic(n):
        steps = 0
        for _ in range(n):
            for _ in range(n):
                steps += 1
        return steps
    
    
    for n in (8, 16, 32):
        print(n, count_log(n), count_linear(n), count_quadratic(n))
    assert count_log(1024) == 10
  39. 39

    Recurrences and the master theorem

    Describe divide-and-conquer cost with a recurrence and check it against its closed form.

    Use the master theorem to classify T(n) = 4T(n/2) + n.

    Reveal answer and reasoning

    a = 4 and b = 2, so n^(log₂ 4) = n². Since f(n) = n grows polynomially slower than n², the leaves dominate and T(n) = Θ(n²).

    Show starting example
    Python
    from functools import lru_cache
    
    
    @lru_cache(maxsize=None)
    def t(n):
        if n == 1:
            return 0
        return 2 * t(n // 2) + n
    
    
    def closed_form(n):
        return n * (n.bit_length() - 1)
    
    
    for n in (1, 2, 4, 8, 16, 1024):
        print(n, t(n), closed_form(n))
        assert t(n) == closed_form(n)
  40. 40

    Amortized analysis

    Show that an occasional expensive step still gives constant average cost per operation.

    Change the loop to 17 appends and predict capacity and copies.

    Reveal answer and reasoning

    The 17th append finds the array full at size 16, so the capacity becomes 32 and 16 more elements are copied: capacity 32, copies 31.

    Show starting example
    Python
    class DynamicArray:
        def __init__(self):
            self.capacity = 1
            self.size = 0
            self.copies = 0
    
        def append(self):
            if self.size == self.capacity:
                self.capacity *= 2
                self.copies += self.size
            self.size += 1
    
    
    array = DynamicArray()
    for _ in range(16):
        array.append()
    print(array.size, array.capacity, array.copies)
    print(array.copies / array.size)
    assert array.copies < 2 * array.size
  41. 41

    Best, worst, and average case

    Measure comparisons for lucky, unlucky, and typical inputs to the same algorithm.

    For a list of 9 distinct items, what is the average number of comparisons for a present, uniformly placed target?

    Reveal answer and reasoning

    (9 + 1) / 2 = 5 comparisons.

    Show starting example
    Python
    def comparisons(items, target):
        count = 0
        for item in items:
            count += 1
            if item == target:
                break
        return count
    
    
    items = [10, 20, 30, 40, 50]
    best = comparisons(items, 10)
    worst = comparisons(items, 50)
    miss = comparisons(items, 99)
    average = sum(comparisons(items, value) for value in items) / len(items)
    print(best, worst, miss)
    print(average)
    assert average == (len(items) + 1) / 2
  42. 42

    Correctness with loop invariants

    Prove that insertion sort is correct by checking an invariant before every iteration.

    State a loop invariant for a function that sums a list with a for loop and an accumulator total.

    Reveal answer and reasoning

    Before each iteration, total equals the sum of the items already visited. It is true initially (an empty sum is 0), preserved by adding the next item, and at the end total is the sum of all items.

    Show starting example
    Python
    def insertion_sort(values):
        items = list(values)
        for i in range(1, len(items)):
            assert items[:i] == sorted(items[:i])
            key = items[i]
            j = i - 1
            while j >= 0 and items[j] > key:
                items[j + 1] = items[j]
                j -= 1
            items[j + 1] = key
        assert items == sorted(items)
        return items
    
    
    print(insertion_sort([5, 2, 4, 6, 1, 3]))
    print(insertion_sort([]))
  43. 43

    Time–space trade-offs with memoization

    Spend memory on a cache to turn exponential recursion into linear work.

    How many calls does fib_memo(10, {}) make?

    Reveal answer and reasoning

    19. There is one top-level call plus two child calls for each of the nine values from 2 to 10; every later request for a smaller value is a cache hit that still counts as a call. So 1 + 2 × 9 = 19.

    Show starting example
    Python
    calls = 0
    
    
    def fib_slow(n):
        global calls
        calls += 1
        return n if n < 2 else fib_slow(n - 1) + fib_slow(n - 2)
    
    
    def fib_memo(n, cache):
        global calls
        calls += 1
        if n in cache:
            return cache[n]
        cache[n] = n if n < 2 else fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
        return cache[n]
    
    
    calls = 0
    slow = fib_slow(20)
    slow_calls = calls
    calls = 0
    fast = fib_memo(20, {})
    fast_calls = calls
    print(slow, slow_calls)
    print(fast, fast_calls)
    assert slow == fast
  44. 44

    The relational model and constraints

    Define tables with keys and constraints so the database rejects invalid data.

    Add a UNIQUE constraint on employee.name and predict what happens when 'Ada' is inserted again.

    Reveal answer and reasoning

    The second insert raises sqlite3.IntegrityError with a message starting UNIQUE constraint failed, and the row is not stored.

    Show starting example
    Python
    import sqlite3
    
    db = sqlite3.connect(":memory:")
    db.execute("PRAGMA foreign_keys = ON")
    db.execute("CREATE TABLE department (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)")
    db.execute(
        "CREATE TABLE employee ("
        "id INTEGER PRIMARY KEY, name TEXT NOT NULL, "
        "salary INTEGER NOT NULL CHECK (salary > 0), "
        "department_id INTEGER NOT NULL REFERENCES department(id))"
    )
    db.execute("INSERT INTO department (id, name) VALUES (1, 'Engineering')")
    db.execute("INSERT INTO employee (name, salary, department_id) VALUES ('Ada', 120, 1)")
    
    bad_statements = [
        "INSERT INTO employee (name, salary, department_id) VALUES ('Bob', 100, 99)",
        "INSERT INTO employee (name, salary, department_id) VALUES ('Cy', -5, 1)",
        "INSERT INTO department (id, name) VALUES (2, 'Engineering')",
    ]
    for statement in bad_statements:
        try:
            db.execute(statement)
        except sqlite3.IntegrityError as error:
            print(str(error).split(":")[0])
    print(db.execute("SELECT COUNT(*) FROM employee").fetchone()[0])
  45. 45

    Querying with SELECT, GROUP BY, and HAVING

    Filter rows, group them, filter the groups, and sort the results.

    Change WHERE amount >= 8 to WHERE amount >= 20. Which rows come back?

    Reveal answer and reasoning

    Only the rows with amount 25, 40 survive, giving ('south', 1, 40) and ('north', 1, 25) after HAVING SUM(amount) > 20.

    Show starting example
    Python
    import sqlite3
    
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE sale (region TEXT, product TEXT, amount INTEGER)")
    db.executemany(
        "INSERT INTO sale VALUES (?, ?, ?)",
        [
            ("north", "pen", 10),
            ("north", "ink", 25),
            ("south", "pen", 5),
            ("south", "ink", 40),
            ("south", "pad", 15),
            ("east", "pen", 8),
        ],
    )
    rows = db.execute(
        "SELECT region, COUNT(*) AS orders, SUM(amount) AS total "
        "FROM sale WHERE amount >= 8 "
        "GROUP BY region HAVING SUM(amount) > 20 "
        "ORDER BY total DESC"
    ).fetchall()
    for row in rows:
        print(row)
  46. 46

    Joins: combining tables

    Match rows across tables with INNER and LEFT joins and keep rows that have no match.

    Which query lists authors who have written no books?

    Reveal answer and reasoning

    SELECT author.name FROM author LEFT JOIN book ON book.author_id = author.id WHERE book.id IS NULL. It returns Linus.

    Show starting example
    Python
    import sqlite3
    
    db = sqlite3.connect(":memory:")
    db.executescript(
        """
        CREATE TABLE author (id INTEGER PRIMARY KEY, name TEXT);
        CREATE TABLE book (id INTEGER PRIMARY KEY, title TEXT, author_id INTEGER);
        INSERT INTO author VALUES (1, 'Ada'), (2, 'Grace'), (3, 'Linus');
        INSERT INTO book VALUES (1, 'Notes', 1), (2, 'Compilers', 2), (3, 'Languages', 2);
        """
    )
    inner = db.execute(
        "SELECT author.name, book.title FROM author "
        "JOIN book ON book.author_id = author.id ORDER BY author.name, book.title"
    ).fetchall()
    left = db.execute(
        "SELECT author.name, COUNT(book.id) FROM author "
        "LEFT JOIN book ON book.author_id = author.id "
        "GROUP BY author.id ORDER BY author.id"
    ).fetchall()
    print(inner)
    print(left)
  47. 47

    Normalization and update anomalies

    Split a redundant table so each fact is stored once and updates cannot contradict themselves.

    Which dependency does the flat table contain that makes it violate third normal form?

    Reveal answer and reasoning

    customer determines city, but customer is not a key of the flat table, so city depends on a non-key column. That is a transitive dependency on the key order_id.

    Show starting example
    Python
    import sqlite3
    
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE flat (order_id INTEGER, customer TEXT, city TEXT, item TEXT)")
    db.executemany(
        "INSERT INTO flat VALUES (?, ?, ?, ?)",
        [(1, "Ada", "London", "pen"), (2, "Ada", "London", "ink"), (3, "Bo", "Paris", "pad")],
    )
    db.execute("UPDATE flat SET city = 'Cambridge' WHERE order_id = 1")
    print(db.execute("SELECT DISTINCT city FROM flat WHERE customer = 'Ada' ORDER BY city").fetchall())
    
    db.executescript(
        """
        CREATE TABLE customer (name TEXT PRIMARY KEY, city TEXT NOT NULL);
        CREATE TABLE purchase (
            order_id INTEGER PRIMARY KEY,
            customer TEXT NOT NULL REFERENCES customer(name),
            item TEXT NOT NULL
        );
        INSERT INTO customer VALUES ('Ada', 'London'), ('Bo', 'Paris');
        INSERT INTO purchase VALUES (1, 'Ada', 'pen'), (2, 'Ada', 'ink'), (3, 'Bo', 'pad');
        """
    )
    db.execute("UPDATE customer SET city = 'Cambridge' WHERE name = 'Ada'")
    print(
        db.execute(
            "SELECT DISTINCT c.city FROM purchase p "
            "JOIN customer c ON c.name = p.customer WHERE p.customer = 'Ada'"
        ).fetchall()
    )
  48. 48

    Transactions and ACID

    Group updates so they all happen or none do, and roll back when a rule fails.

    What would the final balances be if transfer(2, 1, 20) ran after the example?

    Reveal answer and reasoning

    Account 2 loses 20 and account 1 gains 20, so the balances become [(1, 90), (2, 60)].

    Show starting example
    Python
    import sqlite3
    
    db = sqlite3.connect(":memory:", isolation_level=None)
    db.execute("CREATE TABLE account (id INTEGER PRIMARY KEY, balance INTEGER NOT NULL CHECK (balance >= 0))")
    db.execute("INSERT INTO account VALUES (1, 100), (2, 50)")
    
    
    def transfer(source, target, amount):
        db.execute("BEGIN")
        try:
            db.execute("UPDATE account SET balance = balance + ? WHERE id = ?", (amount, target))
            db.execute("UPDATE account SET balance = balance - ? WHERE id = ?", (amount, source))
            db.execute("COMMIT")
            return True
        except sqlite3.IntegrityError:
            db.execute("ROLLBACK")
            return False
    
    
    print(transfer(1, 2, 30))
    print(transfer(1, 2, 500))
    print(db.execute("SELECT id, balance FROM account ORDER BY id").fetchall())
  49. 49

    Indexes and query plans

    See how an index changes a table scan into a search by reading the query plan.

    Would the email index help the query WHERE age = 30? Why?

    Reveal answer and reasoning

    No. The index is sorted by email, so it cannot locate rows by age. An index on age would be needed.

    Show starting example
    Python
    import sqlite3
    
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE person (id INTEGER PRIMARY KEY, email TEXT, age INTEGER)")
    db.executemany(
        "INSERT INTO person (email, age) VALUES (?, ?)",
        [(f"user{i}@example.com", 20 + i % 40) for i in range(1000)],
    )
    query = "SELECT id FROM person WHERE email = 'user500@example.com'"
    
    
    def plan(sql):
        return " ".join(row[3] for row in db.execute("EXPLAIN QUERY PLAN " + sql))
    
    
    before = plan(query)
    db.execute("CREATE INDEX idx_person_email ON person(email)")
    after = plan(query)
    print("SCAN" in before, "SEARCH" in after, "idx_person_email" in after)
    print(db.execute(query).fetchall())
  50. 50

    CPU scheduling: FCFS and shortest job first

    Compare average waiting time when the scheduler runs jobs in arrival order or by shortest burst.

    Why does P3 wait 15 under SJF but only 10 under FCFS?

    Reveal answer and reasoning

    SJF lets the shorter jobs P2 and P4 run before the long job P3, so P3 starts at 17 instead of 12. Its wait rises, but the total waiting time of all jobs falls.

    Show starting example
    Python
    jobs = [("P1", 0, 8), ("P2", 1, 4), ("P3", 2, 9), ("P4", 3, 5)]
    
    
    def simulate(jobs, pick):
        time = 0
        pending = sorted(jobs, key=lambda job: job[1])
        waits = {}
        order = []
        while pending:
            ready = [job for job in pending if job[1] <= time]
            if not ready:
                time = min(job[1] for job in pending)
                continue
            job = pick(ready)
            pending.remove(job)
            name, arrival, burst = job
            waits[name] = time - arrival
            time += burst
            order.append(name)
        return order, sum(waits.values()) / len(waits)
    
    
    fcfs = simulate(jobs, lambda ready: min(ready, key=lambda job: job[1]))
    sjf = simulate(jobs, lambda ready: min(ready, key=lambda job: (job[2], job[1])))
    print(fcfs)
    print(sjf)
    assert sjf[1] < fcfs[1]
  51. 51

    Round-robin scheduling and the time quantum

    Share the CPU fairly by running each job for at most one quantum, then rotating the queue.

    What are the finish times with a quantum of 8?

    Reveal answer and reasoning

    With a quantum at least as large as every burst, each job runs once to completion in queue order: A finishes at 5, B at 8, and C at 16. That is the same order FCFS would produce.

    Show starting example
    Python
    from collections import deque
    
    
    def round_robin(bursts, quantum):
        queue = deque(bursts.items())
        time = 0
        finish = {}
        while queue:
            name, remaining = queue.popleft()
            run = min(quantum, remaining)
            time += run
            if remaining > run:
                queue.append((name, remaining - run))
            else:
                finish[name] = time
        return finish
    
    
    bursts = {"A": 5, "B": 3, "C": 8}
    finish = round_robin(bursts, 3)
    print(finish)
    waits = {name: finish[name] - bursts[name] for name in bursts}
    print(waits)
    assert sum(finish.values()) > 0
  52. 52

    Page replacement: FIFO, LRU, and optimal

    Count page faults for three replacement policies and see Belady's anomaly with FIFO.

    How many faults does LRU produce for the Belady string with 3 frames, and with 4 frames?

    Reveal answer and reasoning

    10 faults with 3 frames and 8 with 4 frames. LRU is a stack algorithm, so adding frames can never increase the number of faults.

    Show starting example
    Python
    def fifo(refs, frames):
        memory, faults = [], 0
        for page in refs:
            if page not in memory:
                faults += 1
                if len(memory) == frames:
                    memory.pop(0)
                memory.append(page)
        return faults
    
    
    def lru(refs, frames):
        memory, faults = [], 0
        for page in refs:
            if page in memory:
                memory.remove(page)
            else:
                faults += 1
                if len(memory) == frames:
                    memory.pop(0)
            memory.append(page)
        return faults
    
    
    def optimal(refs, frames):
        memory, faults = [], 0
        for i, page in enumerate(refs):
            if page in memory:
                continue
            faults += 1
            if len(memory) == frames:
                future = refs[i + 1:]
                victim = max(memory, key=lambda p: future.index(p) if p in future else len(future) + 1)
                memory.remove(victim)
            memory.append(page)
        return faults
    
    
    refs = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
    print(fifo(refs, 3), lru(refs, 3), optimal(refs, 3))
    belady = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
    print(fifo(belady, 3), fifo(belady, 4))
  53. 53

    Deadlock avoidance with the banker's algorithm

    Grant a resource request only if the resulting state is safe: every process can still finish.

    Why must a process's whole remaining need fit in available resources before it can finish?

    Reveal answer and reasoning

    The process may request all of its remaining need at once. Only if that can be satisfied is it guaranteed to finish and release what it holds.

    Show starting example
    Python
    def safe_sequence(available, allocation, maximum):
        need = [[m - a for m, a in zip(mx, al)] for mx, al in zip(maximum, allocation)]
        work = list(available)
        finished = [False] * len(allocation)
        order = []
        progress = True
        while progress:
            progress = False
            for i, row in enumerate(need):
                if not finished[i] and all(n <= w for n, w in zip(row, work)):
                    work = [w + a for w, a in zip(work, allocation[i])]
                    finished[i] = True
                    order.append(f"P{i}")
                    progress = True
        return order if all(finished) else None
    
    
    def try_request(available, allocation, maximum, process, request):
        if any(r > a for r, a in zip(request, available)):
            return None
        new_available = [a - r for a, r in zip(available, request)]
        new_allocation = [row[:] for row in allocation]
        new_allocation[process] = [x + r for x, r in zip(new_allocation[process], request)]
        if safe_sequence(new_available, new_allocation, maximum) is None:
            return None
        return new_available, new_allocation
    
    
    available = [3, 3, 2]
    allocation = [[0, 1, 0], [2, 0, 0], [3, 0, 2], [2, 1, 1], [0, 0, 2]]
    maximum = [[7, 5, 3], [3, 2, 2], [9, 0, 2], [2, 2, 2], [4, 3, 3]]
    print(safe_sequence(available, allocation, maximum))
    
    state = (available, allocation)
    for process, request in ((1, [1, 0, 2]), (4, [3, 3, 0]), (0, [0, 2, 0])):
        outcome = try_request(state[0], state[1], maximum, process, request)
        print(f"P{process} {request}:", "granted" if outcome else "denied")
        if outcome:
            state = outcome
  54. 54

    Synchronization: locks and the bounded buffer

    Protect shared data with a lock and coordinate a producer and consumer with a blocking queue.

    What would happen if the consumer never received the None value?

    Reveal answer and reasoning

    After the five items were consumed, get() would block forever on the empty queue and the program would hang. The sentinel tells the consumer to stop.

    Show starting example
    Python
    import queue
    import threading
    
    counter = 0
    lock = threading.Lock()
    
    
    def work():
        global counter
        for _ in range(10000):
            with lock:
                counter += 1
    
    
    workers = [threading.Thread(target=work) for _ in range(4)]
    for worker in workers:
        worker.start()
    for worker in workers:
        worker.join()
    
    buffer = queue.Queue(maxsize=2)
    results = []
    
    
    def producer():
        for item in range(1, 6):
            buffer.put(item)
        buffer.put(None)
    
    
    def consumer():
        while True:
            item = buffer.get()
            if item is None:
                break
            results.append(item * item)
    
    
    threads = [threading.Thread(target=producer), threading.Thread(target=consumer)]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    print(results)
    print(counter)
  55. 55

    Virtual memory: address translation with a page table

    Split a virtual address into page number and offset and map it to a physical frame.

    What physical address does virtual address 12289 map to?

    Reveal answer and reasoning

    12289 is page 3, offset 1, and page 3 is in frame 7, so the physical address is 7 × 4096 + 1 = 28673.

    Show starting example
    Python
    PAGE_SIZE = 4096
    page_table = {0: 5, 1: 2, 2: None, 3: 7}
    
    
    def translate(address):
        page, offset = divmod(address, PAGE_SIZE)
        if page not in page_table:
            return "segmentation fault"
        frame = page_table[page]
        if frame is None:
            return "page fault"
        return frame * PAGE_SIZE + offset
    
    
    print(PAGE_SIZE.bit_length() - 1)
    for address in (0, 4100, 8200, 16383, 20000):
        print(address, translate(address))
  56. 56

    Protocol layers and encapsulation

    Wrap data in transport, network, and link headers on the way down and unwrap it on the way up.

    Which header does a router read to decide where to forward a packet?

    Reveal answer and reasoning

    The IP (network layer) header, specifically the destination IP address. It does not need the transport or application data.

    Show starting example
    Python
    def encapsulate(payload):
        segment = {"src_port": 50000, "dst_port": 80, "data": payload}
        packet = {"src_ip": "192.0.2.10", "dst_ip": "198.51.100.7", "data": segment}
        frame = {"src_mac": "aa:aa:aa:aa:aa:01", "dst_mac": "bb:bb:bb:bb:bb:02", "data": packet}
        return frame
    
    
    def decapsulate(frame):
        packet = frame["data"]
        segment = packet["data"]
        return segment["data"]
    
    
    frame = encapsulate("GET /")
    print(list(frame))
    print(frame["data"]["dst_ip"], frame["data"]["data"]["dst_port"])
    print(decapsulate(frame))
  57. 57

    IP addressing, CIDR, and subnets

    Read a prefix length, count addresses and usable hosts, and split a network into subnets.

    How many usable host addresses does a /28 IPv4 network have?

    Reveal answer and reasoning

    A /28 leaves 4 host bits, so 16 addresses and 14 usable hosts after removing the network and broadcast addresses.

    Show starting example
    Python
    import ipaddress
    
    network = ipaddress.ip_network("192.168.10.0/26")
    print(network.netmask, network.num_addresses)
    hosts = list(network.hosts())
    print(hosts[0], hosts[-1], len(hosts))
    print(ipaddress.ip_address("192.168.10.70") in network)
    subnets = list(ipaddress.ip_network("192.168.10.0/24").subnets(new_prefix=26))
    print([str(subnet) for subnet in subnets])
  58. 58

    TCP sockets: a loopback echo server

    Open a listening socket, connect a client, and exchange bytes over a reliable stream.

    Why does the program bind to port 0 instead of a fixed port such as 8080?

    Reveal answer and reasoning

    A fixed port could already be in use on the machine. Port 0 lets the operating system pick a free port, which getsockname() then reports.

    Show starting example
    Python
    import socket
    import threading
    
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(("127.0.0.1", 0))
    server.listen(1)
    port = server.getsockname()[1]
    
    
    def serve():
        connection, _ = server.accept()
        with connection:
            data = connection.recv(1024)
            connection.sendall(data.upper())
    
    
    thread = threading.Thread(target=serve)
    thread.start()
    with socket.create_connection(("127.0.0.1", port)) as client:
        client.sendall(b"hello tcp")
        reply = client.recv(1024)
    thread.join()
    server.close()
    print(port > 0)
    print(reply.decode())
  59. 59

    UDP datagrams

    Send independent datagrams with no connection and see how UDP differs from TCP.

    Which protocol would you choose for a live video call, and why?

    Reveal answer and reasoning

    Typically UDP-based transport. A late frame is worthless, so retransmitting old data (as TCP would, with head-of-line blocking) hurts more than skipping it.

    Show starting example
    Python
    import socket
    
    receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    receiver.bind(("127.0.0.1", 0))
    address = receiver.getsockname()
    
    sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sender.sendto(b"one", address)
    sender.sendto(b"two", address)
    
    first, _ = receiver.recvfrom(1024)
    second, _ = receiver.recvfrom(1024)
    sender.close()
    receiver.close()
    print(first, second)
  60. 60

    HTTP messages: requests, responses, and status codes

    Read the text structure of an HTTP/1.1 request and response, and parse a response by hand.

    Which status code class does 404 belong to, and who is at fault?

    Reveal answer and reasoning

    4xx is a client error. 404 means the server could not find the requested resource; the request, rather than the server's internals, is the problem.

    Show starting example
    Python
    request = "\r\n".join(["GET /index.html HTTP/1.1", "Host: example.com", "Accept: text/html", "", ""])
    response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"
    
    
    def parse_response(text):
        head, body = text.split("\r\n\r\n", 1)
        status_line, *header_lines = head.split("\r\n")
        version, status, reason = status_line.split(" ", 2)
        headers = {}
        for line in header_lines:
            name, value = line.split(":", 1)
            headers[name.strip().lower()] = value.strip()
        return int(status), reason, headers, body
    
    
    method, target, version = request.split("\r\n")[0].split(" ")
    print(method, target, version)
    status, reason, headers, body = parse_response(response)
    print(status, reason)
    print(headers["content-length"], len(body.encode()))
    print(body)
  61. 61

    Forwarding with longest-prefix match

    Choose the next hop by finding the most specific route that matches a destination address.

    Which next hop is chosen for 10.1.2.255, and for 10.2.0.1?

    Reveal answer and reasoning

    10.1.2.255 is inside the /24, so the lab switch. 10.2.0.1 matches only the /8 and the default, so the corporate core.

    Show starting example
    Python
    import ipaddress
    
    routes = [
        (ipaddress.ip_network("0.0.0.0/0"), "default gateway"),
        (ipaddress.ip_network("10.0.0.0/8"), "corporate core"),
        (ipaddress.ip_network("10.1.0.0/16"), "branch office"),
        (ipaddress.ip_network("10.1.2.0/24"), "lab switch"),
    ]
    
    
    def next_hop(destination):
        address = ipaddress.ip_address(destination)
        matches = [(network.prefixlen, hop) for network, hop in routes if address in network]
        return max(matches)[1]
    
    
    for destination in ("10.1.2.9", "10.1.9.9", "10.200.0.1", "8.8.8.8"):
        print(destination, next_hop(destination))
  62. 62

    Unit testing with unittest

    Write small automated tests that check normal behavior, boundaries, and error handling.

    Add a test for apply_discount(1000, 100). What should it return?

    Reveal answer and reasoning

    0. A 100 percent discount leaves nothing to pay, and 100 is a valid boundary value.

    Show starting example
    Python
    import io
    import unittest
    
    
    def apply_discount(price_cents, percent):
        if not 0 <= percent <= 100:
            raise ValueError("percent must be between 0 and 100")
        return price_cents * (100 - percent) // 100
    
    
    class DiscountTests(unittest.TestCase):
        def test_typical_discount(self):
            self.assertEqual(apply_discount(8000, 25), 6000)
    
        def test_zero_discount_keeps_price(self):
            self.assertEqual(apply_discount(1999, 0), 1999)
    
        def test_invalid_percent_is_rejected(self):
            with self.assertRaises(ValueError):
                apply_discount(1000, 120)
    
    
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(DiscountTests)
    result = unittest.TextTestRunner(stream=io.StringIO()).run(suite)
    print(result.testsRun, result.wasSuccessful())
  63. 63

    Refactoring safely with tests

    Restructure code without changing its behavior, and prove that behavior is unchanged.

    Add a third tier: weights up to 10 cost 15 standard and 30 express, and above that 20 and 40. Which line changes?

    Reveal answer and reasoning

    Add (10, 15, 30) to RATES after (5, 10, 25) and change the final fallback prices to 20 and 40. The loop needs no changes, which is the benefit of the table.

    Show starting example
    Python
    def shipping_before(weight, express):
        if express == True:
            if weight <= 1:
                return 15
            else:
                if weight <= 5:
                    return 25
                else:
                    return 40
        else:
            if weight <= 1:
                return 5
            else:
                if weight <= 5:
                    return 10
                else:
                    return 20
    
    
    RATES = [(1, 5, 15), (5, 10, 25)]
    
    
    def shipping_after(weight, express):
        for limit, standard, fast in RATES:
            if weight <= limit:
                return fast if express else standard
        return 40 if express else 20
    
    
    cases = [(w, e) for w in (0, 1, 1.5, 5, 5.1, 50) for e in (False, True)]
    assert all(shipping_before(w, e) == shipping_after(w, e) for w, e in cases)
    print(len(cases), "cases identical")
    print(shipping_after(3, True), shipping_after(30, False))
  64. 64

    SOLID design: depend on abstractions

    Pass behavior into a class so it can be extended without being edited.

    Write a rule that charges a minimum of 1000 cents. What must change in Checkout?

    Reveal answer and reasoning

    Nothing. Add class MinimumCharge with apply returning max(amount, 1000) and pass an instance to Checkout.

    Show starting example
    Python
    class NoDiscount:
        def apply(self, amount):
            return amount
    
    
    class PercentOff:
        def __init__(self, percent):
            self.percent = percent
    
        def apply(self, amount):
            return amount * (100 - self.percent) // 100
    
    
    class FlatOff:
        def __init__(self, cents):
            self.cents = cents
    
        def apply(self, amount):
            return max(amount - self.cents, 0)
    
    
    class Checkout:
        def __init__(self, rule):
            self.rule = rule
    
        def total(self, prices):
            return self.rule.apply(sum(prices))
    
    
    cart = [1000, 2500]
    for rule in (NoDiscount(), PercentOff(10), FlatOff(500)):
        print(type(rule).__name__, Checkout(rule).total(cart))
  65. 65

    The observer pattern and an event bus

    Let a publisher notify many subscribers without knowing who they are.

    How would you add an analytics reaction to order_placed?

    Reveal answer and reasoning

    Call bus.subscribe("order_placed", record_analytics). The publisher and the existing handlers stay unchanged.

    Show starting example
    Python
    class EventBus:
        def __init__(self):
            self.handlers = {}
    
        def subscribe(self, event, handler):
            self.handlers.setdefault(event, []).append(handler)
    
        def publish(self, event, payload):
            for handler in self.handlers.get(event, []):
                handler(payload)
    
    
    log = []
    bus = EventBus()
    bus.subscribe("order_placed", lambda order: log.append(f"email {order}"))
    bus.subscribe("order_placed", lambda order: log.append(f"stock {order}"))
    bus.publish("order_placed", "#42")
    bus.publish("order_cancelled", "#43")
    print(log)
  66. 66

    How version control works: commits, history, and merge bases

    Model a commit graph as hashes and parents, and find the common ancestor used to merge branches.

    What is the merge base of merged and feature in the example?

    Reveal answer and reasoning

    feature itself. It is an ancestor of merged, and it is the deepest commit reachable from both, so the merge base is 'add lexer'.

    Show starting example
    Python
    import hashlib
    import json
    
    commits = {}
    
    
    def commit(message, parents):
        body = json.dumps({"message": message, "parents": parents}, sort_keys=True)
        commit_id = hashlib.sha256(body.encode()).hexdigest()[:8]
        commits[commit_id] = {"message": message, "parents": parents}
        return commit_id
    
    
    def ancestors(commit_id):
        seen = {commit_id}
        stack = [commit_id]
        while stack:
            for parent in commits[stack.pop()]["parents"]:
                if parent not in seen:
                    seen.add(parent)
                    stack.append(parent)
        return seen
    
    
    def depth(commit_id):
        parents = commits[commit_id]["parents"]
        return 0 if not parents else 1 + max(depth(parent) for parent in parents)
    
    
    root = commit("init", [])
    parser = commit("add parser", [root])
    main = commit("add tests", [parser])
    feature = commit("add lexer", [parser])
    merged = commit("merge feature", [main, feature])
    
    common = ancestors(main) & ancestors(feature)
    base = max(common, key=depth)
    print(commits[base]["message"])
    print(sorted(commits[c]["message"] for c in ancestors(merged)))
    print(commit("init", []) == root)
  67. 67

    Contracts, invariants, and exceptions

    State what a function requires and guarantees, and fail fast with clear errors.

    Which exception does Account(-5) raise, and why is it raised in __init__?

    Reveal answer and reasoning

    ValueError. The constructor must establish the invariant that the balance is never negative; refusing to create an invalid object is better than letting it exist.

    Show starting example
    Python
    class InsufficientFunds(Exception):
        pass
    
    
    class Account:
        def __init__(self, balance):
            if balance < 0:
                raise ValueError("opening balance cannot be negative")
            self._balance = balance
    
        @property
        def balance(self):
            return self._balance
    
        def withdraw(self, amount):
            if amount <= 0:
                raise ValueError("amount must be positive")
            if amount > self._balance:
                raise InsufficientFunds(f"balance {self._balance} is less than {amount}")
            self._balance -= amount
            assert self._balance >= 0
            return self._balance
    
    
    account = Account(100)
    print(account.withdraw(30))
    for amount in (0, 500):
        try:
            account.withdraw(amount)
        except InsufficientFunds as error:
            print("rejected:", error)
        except ValueError as error:
            print("invalid:", error)
    print(account.balance)
  68. 68

    Cryptographic hashing and integrity

    Compute SHA-256 digests and see why a one-character change produces a completely different hash.

    Why can't you recover the original message from its SHA-256 digest?

    Reveal answer and reasoning

    The digest is only 256 bits, so many different inputs share the same digest in principle. The function is designed so that finding any input for a given digest is computationally infeasible.

    Show starting example
    Python
    import hashlib
    
    print(hashlib.sha256(b"abc").hexdigest())
    first = hashlib.sha256(b"transfer 100 to alice").hexdigest()
    second = hashlib.sha256(b"transfer 900 to alice").hexdigest()
    differing = sum(x != y for x, y in zip(first, second))
    print(len(first), first == second)
    print(differing > 40)
  69. 69

    Storing passwords safely

    Hash passwords with a unique salt and a deliberately slow function, then verify in constant time.

    Why must the salt be stored next to the hash if it is not secret?

    Reveal answer and reasoning

    To verify a login the system must recompute the hash with the same salt. The salt's job is uniqueness, which defeats precomputed tables, not secrecy.

    Show starting example
    Python
    import hashlib
    import hmac
    import os
    
    ITERATIONS = 600_000
    
    
    def hash_password(password, salt):
        return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS)
    
    
    def verify(password, salt, stored):
        return hmac.compare_digest(hash_password(password, salt), stored)
    
    
    salt_a = os.urandom(16)
    salt_b = os.urandom(16)
    stored = hash_password("correct horse", salt_a)
    print(verify("correct horse", salt_a, stored))
    print(verify("wrong guess", salt_a, stored))
    print(hash_password("correct horse", salt_a) == hash_password("correct horse", salt_b))
    print(len(salt_a), len(stored))
  70. 70

    Message authentication with HMAC

    Attach a keyed tag to a message so that tampering is detected by anyone who holds the key.

    What does a MAC prove that a plain SHA-256 hash of the message does not?

    Reveal answer and reasoning

    That the message was produced by someone who knows the secret key. Anyone can compute a plain hash, so an attacker could replace both message and hash.

    Show starting example
    Python
    import hashlib
    import hmac
    
    key = b"shared-secret-key"
    
    
    def sign(message):
        return hmac.new(key, message, hashlib.sha256).hexdigest()
    
    
    def verify(message, tag):
        return hmac.compare_digest(sign(message), tag)
    
    
    message = b"amount=100&to=alice"
    tag = sign(message)
    print(verify(message, tag))
    print(verify(b"amount=900&to=alice", tag))
    print(verify(message, "0" * 64))
    print(len(tag))
  71. 71

    The one-time pad and why keys must never be reused

    Use XOR as a cipher and see how reusing a key leaks the relationship between two messages.

    Why does a one-time pad key have to be as long as the message?

    Reveal answer and reasoning

    A shorter key must repeat, and repeating key bytes has the same weakness as reusing a key: it leaks relationships between plaintext parts.

    Show starting example
    Python
    def xor(data, key):
        return bytes(d ^ k for d, k in zip(data, key))
    
    
    plain1 = b"ATTACK AT DAWN"
    plain2 = b"RETREAT AT TEN"
    key = bytes(range(1, 15))
    cipher1 = xor(plain1, key)
    cipher2 = xor(plain2, key)
    print(xor(cipher1, key) == plain1)
    leak = xor(cipher1, cipher2)
    print(leak == xor(plain1, plain2))
    print(xor(leak, plain1))
  72. 72

    SQL injection and parameterized queries

    See how string-built queries let input change the query, and how placeholders prevent it.

    Rewrite unsafe(name) to also filter on a numeric id passed by the caller.

    Reveal answer and reasoning

    Use two placeholders: db.execute("SELECT name FROM users WHERE name = ? AND rowid = ?", (name, user_id)). Both values are bound as data.

    Show starting example
    Python
    import sqlite3
    
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE users (name TEXT, secret TEXT)")
    db.executemany("INSERT INTO users VALUES (?, ?)", [("ada", "s3cret-a"), ("bob", "s3cret-b")])
    
    
    def unsafe(name):
        return db.execute("SELECT name FROM users WHERE name = '" + name + "'").fetchall()
    
    
    def safe(name):
        return db.execute("SELECT name FROM users WHERE name = ?", (name,)).fetchall()
    
    
    attack = "' OR '1'='1"
    print(unsafe("ada"))
    print(unsafe(attack))
    print(safe(attack))
  73. 73

    Secure randomness for tokens and secrets

    Generate unpredictable tokens with the secrets module, and see why the random module is not for security.

    How long is secrets.token_urlsafe(16)?

    Reveal answer and reasoning

    22 characters. 16 bytes is 128 bits and base64url without padding uses ceil(16 × 8 / 6) = 22 characters.

    Show starting example
    Python
    import random
    import secrets
    import string
    
    first = random.Random(1234)
    second = random.Random(1234)
    print([first.randrange(10) for _ in range(5)] == [second.randrange(10) for _ in range(5)])
    
    token = secrets.token_urlsafe(32)
    alphabet = set(string.ascii_letters + string.digits + "-_")
    print(len(token), set(token) <= alphabet)
    print(secrets.token_urlsafe(32) != token)
    print(secrets.compare_digest(token, token))
  74. 74

    Concurrency with thread pools for waiting work

    Overlap tasks that mostly wait, such as network or disk requests, and keep results in order.

    How long would 10 tasks of 0.2 s take with max_workers=5?

    Reveal answer and reasoning

    About 0.4 seconds: two rounds of five concurrent tasks, ceil(10 / 5) × 0.2.

    Show starting example
    Python
    import time
    from concurrent.futures import ThreadPoolExecutor
    
    
    def fetch(n):
        time.sleep(0.2)
        return n * n
    
    
    start = time.perf_counter()
    sequential = [fetch(n) for n in range(5)]
    sequential_time = time.perf_counter() - start
    
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=5) as pool:
        overlapped = list(pool.map(fetch, range(5)))
    overlapped_time = time.perf_counter() - start
    
    print(sequential == overlapped, overlapped)
    print(overlapped_time < sequential_time / 2)
  75. 75

    Parallelism with processes for CPU-bound work

    Split heavy computation across worker processes that run on separate cores.

    Why is the prime count the same for all four tasks?

    Reveal answer and reasoning

    Each task receives the same limit, 20000, and count_primes is a pure function, so every worker computes the same answer.

    Show starting example
    Python
    from multiprocessing import Pool
    
    
    def count_primes(limit):
        return sum(1 for n in range(2, limit) if all(n % d for d in range(2, int(n ** 0.5) + 1)))
    
    
    if __name__ == "__main__":
        limits = [20000, 20000, 20000, 20000]
        with Pool(2) as pool:
            results = pool.map(count_primes, limits)
        print(results)
        print(results == [count_primes(limit) for limit in limits])
  76. 76

    Speed-up limits: Amdahl's and Gustafson's laws

    Compute how much faster a program can get when only part of it runs in parallel.

    What is the maximum Amdahl speed-up when 95 percent of the work is parallel?

    Reveal answer and reasoning

    1 / (1 − 0.95) = 20 times, approached only with a very large number of processors.

    Show starting example
    Python
    def amdahl(parallel_fraction, processors):
        return 1 / ((1 - parallel_fraction) + parallel_fraction / processors)
    
    
    def gustafson(parallel_fraction, processors):
        return (1 - parallel_fraction) + parallel_fraction * processors
    
    
    for processors in (1, 2, 8, 64, 1024):
        print(processors, round(amdahl(0.9, processors), 2), round(gustafson(0.9, processors), 1))
    print(round(1 / (1 - 0.9), 1))
  77. 77

    MapReduce: word count in three phases

    Express a computation as map, shuffle, and reduce steps that can run on many machines.

    Which pairs does the reducer for the key 'sat' receive?

    Reveal answer and reasoning

    Two values, [1, 1], one from the second and one from the first document, and it returns ('sat', 2).

    Show starting example
    Python
    from collections import Counter, defaultdict
    
    documents = ["the cat sat", "the dog sat", "the cat ran"]
    
    
    def map_phase(document):
        return [(word, 1) for word in document.split()]
    
    
    def shuffle(pairs):
        groups = defaultdict(list)
        for key, value in pairs:
            groups[key].append(value)
        return groups
    
    
    def reduce_phase(key, values):
        return key, sum(values)
    
    
    pairs = [pair for document in documents for pair in map_phase(document)]
    grouped = shuffle(pairs)
    result = dict(reduce_phase(key, values) for key, values in sorted(grouped.items()))
    print(result)
    assert result == dict(Counter(" ".join(documents).split()))
  78. 78

    Logical time: Lamport clocks

    Order events across machines without a shared clock by counting events and message receipts.

    If B receives a message with timestamp 10 while its clock is 4, what is the clock afterwards?

    Reveal answer and reasoning

    max(4, 10) + 1 = 11. The receiver's clock jumps past the timestamp carried by the message.

    Show starting example
    Python
    class Process:
        def __init__(self, name):
            self.name = name
            self.clock = 0
    
        def local_event(self):
            self.clock += 1
            return self.clock
    
        def send(self):
            self.clock += 1
            return self.clock
    
        def receive(self, timestamp):
            self.clock = max(self.clock, timestamp) + 1
            return self.clock
    
    
    a = Process("A")
    b = Process("B")
    a.local_event()
    sent = a.send()
    b.local_event()
    received = b.receive(sent)
    print(sent, received)
    print(received > sent)
    print(a.clock, b.clock)
  79. 79

    Replication and quorums

    Read and write a majority of replicas so that every read overlaps the latest write.

    With N = 5, which (W, R) pairs guarantee overlap and tolerate two failed replicas for both reads and writes?

    Reveal answer and reasoning

    W = 3 and R = 3. They overlap since 3 + 3 > 5, and each quorum can still be formed from the 3 replicas that remain after two failures.

    Show starting example
    Python
    from itertools import combinations
    
    N = 3
    replicas = [{"version": 0, "value": None} for _ in range(N)]
    
    
    def write(value, version, targets):
        for i in targets:
            replicas[i] = {"version": version, "value": value}
    
    
    def read(targets):
        return max((replicas[i] for i in targets), key=lambda r: r["version"])["value"]
    
    
    write("v1", 1, [0, 1])
    print(read([1, 2]))
    print(read([2]))
    
    
    def always_overlap(w, r):
        return all(set(ws) & set(rs) for ws in combinations(range(N), w) for rs in combinations(range(N), r))
    
    
    print(always_overlap(2, 2))
    print(always_overlap(2, 1))
  80. 80

    Intelligent agents and environments

    Model an agent that perceives its environment and chooses actions, using a simple reflex vacuum cleaner.

    How would you change the performance measure so the agent is penalized for moving after the world is clean?

    Reveal answer and reasoning

    Charge a cost per move and reward clean squares per time step. Then the best behaviour is to stop once both squares are clean, which a model-based agent that remembers the state can do.

    Show starting example
    Python
    def reflex_agent(location, dirty):
        if dirty:
            return "suck"
        return "right" if location == "A" else "left"
    
    
    def run(world, location, steps):
        actions = []
        for _ in range(steps):
            action = reflex_agent(location, world[location])
            actions.append(action)
            if action == "suck":
                world[location] = False
            elif action == "right":
                location = "B"
            else:
                location = "A"
        return actions
    
    
    world = {"A": True, "B": True}
    print(run(world, "A", 4))
    print(world)
  81. 81

    State-space search: the water jug problem

    Formulate a puzzle as states, actions, and a goal, and find a shortest solution with breadth-first search.

    Why does the program return None for jugs of 2 and 4 litres with a goal of 3?

    Reveal answer and reasoning

    Every amount reachable with capacities 2 and 4 is a multiple of 2, so 3 can never be measured. The search visits all reachable states and finds no goal.

    Show starting example
    Python
    from collections import deque
    
    
    def solve(cap_a, cap_b, goal):
        start = (0, 0)
        parents = {start: None}
        queue = deque([start])
        while queue:
            state = queue.popleft()
            a, b = state
            if goal in state:
                path = []
                while state is not None:
                    path.append(state)
                    state = parents[state]
                return path[::-1]
            pour_ab = min(a, cap_b - b)
            pour_ba = min(b, cap_a - a)
            moves = ((cap_a, b), (a, cap_b), (0, b), (a, 0), (a - pour_ab, b + pour_ab), (a + pour_ba, b - pour_ba))
            for following in moves:
                if following not in parents:
                    parents[following] = state
                    queue.append(following)
        return None
    
    
    path = solve(3, 5, 4)
    print(len(path) - 1)
    print(path)
    print(solve(2, 4, 3))
  82. 82

    A* search with an admissible heuristic

    Guide search toward the goal with a heuristic and compare how many cells it expands against uniform-cost search.

    Is the heuristic 2 × Manhattan distance admissible on this grid?

    Reveal answer and reasoning

    No. It overestimates the true cost of an unobstructed path, which is exactly the Manhattan distance, so A* with it could return a non-optimal solution.

    Show starting example
    Python
    import heapq
    
    GRID = [
        "S.......",
        "........",
        "..####..",
        "........",
        "........",
        ".......G",
    ]
    
    
    def find(symbol):
        for r, row in enumerate(GRID):
            if symbol in row:
                return (r, row.index(symbol))
    
    
    START, GOAL = find("S"), find("G")
    
    
    def manhattan(cell):
        return abs(cell[0] - GOAL[0]) + abs(cell[1] - GOAL[1])
    
    
    def search(heuristic):
        frontier = [(heuristic(START), heuristic(START), 0, START)]
        best = {START: 0}
        expanded = 0
        while frontier:
            _, _, cost, cell = heapq.heappop(frontier)
            if cost > best[cell]:
                continue
            expanded += 1
            if cell == GOAL:
                return cost, expanded
            r, c = cell
            for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
                if 0 <= nr < len(GRID) and 0 <= nc < len(GRID[0]) and GRID[nr][nc] != "#":
                    new_cost = cost + 1
                    if new_cost < best.get((nr, nc), 10**9):
                        best[(nr, nc)] = new_cost
                        h = heuristic((nr, nc))
                        heapq.heappush(frontier, (new_cost + h, h, new_cost, (nr, nc)))
        return None
    
    
    print(search(manhattan))
    print(search(lambda cell: 0))
  83. 83

    Adversarial search: minimax and alpha-beta pruning

    Choose moves in a two-player game and prune branches that cannot change the result.

    What would minimax return for a position where X can win in one move?

    Reveal answer and reasoning

    +1. The winning move yields a terminal score of +1, and the maximizing player picks the maximum of the child values.

    Show starting example
    Python
    LINES = [(0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6)]
    nodes = 0
    
    
    def winner(board):
        for a, b, c in LINES:
            if board[a] != "." and board[a] == board[b] == board[c]:
                return board[a]
        return None
    
    
    def terminal_score(board):
        won = winner(board)
        if won:
            return 1 if won == "X" else -1
        return 0 if "." not in board else None
    
    
    def minimax(board, player):
        global nodes
        nodes += 1
        score = terminal_score(board)
        if score is not None:
            return score
        other = "O" if player == "X" else "X"
        scores = [minimax(board[:i] + player + board[i + 1:], other) for i, cell in enumerate(board) if cell == "."]
        return max(scores) if player == "X" else min(scores)
    
    
    def alphabeta(board, player, alpha, beta):
        global nodes
        nodes += 1
        score = terminal_score(board)
        if score is not None:
            return score
        other = "O" if player == "X" else "X"
        value = -2 if player == "X" else 2
        for i, cell in enumerate(board):
            if cell != ".":
                continue
            child = alphabeta(board[:i] + player + board[i + 1:], other, alpha, beta)
            if player == "X":
                value = max(value, child)
                alpha = max(alpha, value)
            else:
                value = min(value, child)
                beta = min(beta, value)
            if alpha >= beta:
                break
        return value
    
    
    start = "X...O...."
    nodes = 0
    plain = minimax(start, "X")
    plain_nodes = nodes
    nodes = 0
    pruned = alphabeta(start, "X", -2, 2)
    print(plain, pruned)
    print(plain_nodes, nodes)
  84. 84

    Constraint satisfaction: coloring a map

    Assign values to variables so that every constraint holds, using backtracking search.

    Why is two colors impossible for this map?

    Reveal answer and reasoning

    South Australia, Western Australia, and the Northern Territory all touch each other, forming a triangle. A triangle needs three different colors.

    Show starting example
    Python
    NEIGHBORS = {
        "WA": ["NT", "SA"],
        "NT": ["WA", "SA", "Q"],
        "SA": ["WA", "NT", "Q", "NSW", "V"],
        "Q": ["NT", "SA", "NSW"],
        "NSW": ["Q", "SA", "V"],
        "V": ["SA", "NSW"],
        "T": [],
    }
    
    
    def solve(colors):
        regions = list(NEIGHBORS)
    
        def backtrack(assignment):
            if len(assignment) == len(regions):
                return assignment
            region = next(r for r in regions if r not in assignment)
            for color in colors:
                if all(assignment.get(neighbor) != color for neighbor in NEIGHBORS[region]):
                    result = backtrack({**assignment, region: color})
                    if result:
                        return result
            return None
    
        return backtrack({})
    
    
    solution = solve(["red", "green", "blue"])
    print(dict(sorted(solution.items())))
    print(all(solution[a] != solution[b] for a in NEIGHBORS for b in NEIGHBORS[a]))
    print(solve(["red", "green"]))
  85. 85

    Local search: hill climbing for the 8-queens puzzle

    Improve a complete state step by step, and use restarts to escape local optima.

    Why do random restarts help hill climbing?

    Reveal answer and reasoning

    Each start explores a different part of the state space. Even if most climbs end in local optima, one that begins in the basin of a solution reaches it.

    Show starting example
    Python
    def conflicts(board):
        n = len(board)
        return sum(
            1
            for i in range(n)
            for j in range(i + 1, n)
            if board[i] == board[j] or abs(board[i] - board[j]) == j - i
        )
    
    
    def hill_climb(board):
        board = list(board)
        while True:
            current = conflicts(board)
            best, best_move = current, None
            for col in range(len(board)):
                original = board[col]
                for row in range(len(board)):
                    if row != original:
                        board[col] = row
                        score = conflicts(board)
                        if score < best:
                            best, best_move = score, (col, row)
                board[col] = original
            if best_move is None:
                return board, current
            board[best_move[0]] = best_move[1]
    
    
    state = 1
    
    
    def next_start(n=8):
        global state
        board = []
        for _ in range(n):
            state = (state * 1103515245 + 12345) % 2**31
            board.append((state >> 16) % n)
        return board
    
    
    restarts = 0
    while True:
        board, score = hill_climb(next_start())
        if score == 0:
            break
        restarts += 1
    print(restarts, board, score)
  86. 86

    Propositional logic and entailment by model checking

    Decide whether a knowledge base entails a conclusion by checking every truth assignment.

    Does the knowledge base entail the sentence 'rain or cold'?

    Reveal answer and reasoning

    Yes. rain is true in both models of the knowledge base, so 'rain or cold' is true in all of them.

    Show starting example
    Python
    from itertools import product
    
    
    def implies(a, b):
        return (not a) or b
    
    
    def models_of(symbols, formula):
        assignments = (dict(zip(symbols, values)) for values in product([False, True], repeat=len(symbols)))
        return [model for model in assignments if formula(model)]
    
    
    def entails(symbols, kb, query):
        return all(query(model) for model in models_of(symbols, kb))
    
    
    symbols = ["rain", "wet", "cold"]
    kb = lambda m: implies(m["rain"], m["wet"]) and m["rain"]
    print(len(models_of(symbols, kb)))
    print(entails(symbols, kb, lambda m: m["wet"]))
    print(entails(symbols, kb, lambda m: m["cold"]))
    print(entails(symbols, lambda m: False, lambda m: m["cold"]))
  87. 87

    Forward chaining with definite clauses

    Derive new facts from if-then rules until the query is proved or nothing new follows.

    Which fact would you add so that Q can be derived when only A is known?

    Reveal answer and reasoning

    B. With A and B known, the chain L, M, P, Q is derived. Adding L alone would not be enough, because deriving M needs both B and L.

    Show starting example
    Python
    def forward_chain(rules, facts, query):
        known = set(facts)
        derived = []
        changed = True
        while changed:
            changed = False
            for premises, conclusion in rules:
                if conclusion not in known and all(p in known for p in premises):
                    known.add(conclusion)
                    derived.append(conclusion)
                    changed = True
        return query in known, derived
    
    
    rules = [(["P"], "Q"), (["L", "M"], "P"), (["B", "L"], "M"), (["A", "P"], "L"), (["A", "B"], "L")]
    print(forward_chain(rules, ["A", "B"], "Q"))
    print(forward_chain(rules, ["A"], "Q"))
  88. 88

    Resolution and proof by refutation

    Prove a conclusion by adding its negation to the clauses and deriving the empty clause.

    Resolve the clauses {~P, Q} and {P} and name the result.

    Reveal answer and reasoning

    Resolving on P gives {Q}. This is how modus ponens appears in clause form.

    Show starting example
    Python
    from itertools import combinations
    
    
    def negate(literal):
        return literal[1:] if literal.startswith("~") else "~" + literal
    
    
    def resolve(first, second):
        return [(first - {lit}) | (second - {negate(lit)}) for lit in first if negate(lit) in second]
    
    
    def refutes(clauses):
        clauses = set(clauses)
        while True:
            new = set()
            for first, second in combinations(clauses, 2):
                for resolvent in resolve(first, second):
                    if not resolvent:
                        return True
                    new.add(frozenset(resolvent))
            if new <= clauses:
                return False
            clauses |= new
    
    
    kb = [frozenset({"~P", "Q"}), frozenset({"~Q", "R"}), frozenset({"P"})]
    print(refutes(kb + [frozenset({"~R"})]))
    print(refutes(kb + [frozenset({"~S"})]))
  89. 89

    Semantic networks, inheritance, and default reasoning

    Represent categories and properties as a network, and let specific knowledge override general defaults.

    How would you record that 'opus' is a penguin who can fly in a cartoon?

    Reveal answer and reasoning

    Give opus its own definition: parents["opus"] = "penguin" and properties["opus"] = {"flies": True}. The most specific definition overrides penguin's.

    Show starting example
    Python
    parents = {"tweety": "penguin", "penguin": "bird", "bird": "animal", "polly": "parrot", "parrot": "bird"}
    properties = {
        "animal": {"alive": True},
        "bird": {"flies": True, "legs": 2},
        "penguin": {"flies": False},
    }
    
    
    def lookup(thing, attribute):
        while thing is not None:
            if attribute in properties.get(thing, {}):
                return properties[thing][attribute]
            thing = parents.get(thing)
        return None
    
    
    print(lookup("polly", "flies"))
    print(lookup("tweety", "flies"))
    print(lookup("tweety", "legs"), lookup("tweety", "alive"))
    print(lookup("tweety", "color"))
  90. 90

    First-order unification

    Find the substitution that makes two logical terms identical, including the occurs check.

    Do Knows(John, x) and Knows(x, Mary) unify?

    Reveal answer and reasoning

    No. x would have to be John from the first argument and Mary from the second, and John ≠ Mary. Standardizing apart (renaming the variables in one sentence) is what makes the earlier example unify.

    Show starting example
    Python
    def is_variable(term):
        return isinstance(term, str) and term[:1].islower()
    
    
    def occurs(var, term, theta):
        if var == term:
            return True
        if is_variable(term) and term in theta:
            return occurs(var, theta[term], theta)
        if isinstance(term, tuple):
            return any(occurs(var, item, theta) for item in term)
        return False
    
    
    def unify_var(var, term, theta):
        if var in theta:
            return unify(theta[var], term, theta)
        if is_variable(term) and term in theta:
            return unify(var, theta[term], theta)
        if occurs(var, term, theta):
            return None
        return {**theta, var: term}
    
    
    def unify(x, y, theta):
        if theta is None:
            return None
        if x == y:
            return theta
        if is_variable(x):
            return unify_var(x, y, theta)
        if is_variable(y):
            return unify_var(y, x, theta)
        if isinstance(x, tuple) and isinstance(y, tuple) and len(x) == len(y):
            for a, b in zip(x, y):
                theta = unify(a, b, theta)
            return theta
        return None
    
    
    print(sorted(unify(("Knows", "John", "x"), ("Knows", "y", "Mary"), {}).items()))
    print(unify(("P", "x"), ("P", ("F", "x")), {}))
    print(unify(("Knows", "John"), ("Likes", "John"), {}))
    print(sorted(unify(("P", "x", "x"), ("P", "y", "Z"), {}).items()))
  91. 91

    Reasoning under uncertainty: Bayes' rule

    Update beliefs with Bayes' rule and compute a posterior from a small Bayesian network by enumeration.

    Why is the posterior after a positive test so much lower than the 90% sensitivity?

    Reveal answer and reasoning

    Because the disease is rare. With a 1% prior, the 9% false-positive rate applied to the 99% healthy people produces far more positive tests than the 90% detection rate applied to the 1% who are ill.

    Show starting example
    Python
    def posterior(prior, sensitivity, false_positive):
        evidence = sensitivity * prior + false_positive * (1 - prior)
        return sensitivity * prior / evidence
    
    
    print(round(posterior(0.01, 0.9, 0.09), 4), round(posterior(0.10, 0.9, 0.09), 4))
    
    P_RAIN = 0.2
    P_SPRINKLER = {True: 0.01, False: 0.4}
    P_WET = {(True, True): 0.99, (True, False): 0.9, (False, True): 0.8, (False, False): 0.0}
    
    
    def joint(rain, sprinkler, wet):
        p = P_RAIN if rain else 1 - P_RAIN
        p *= P_SPRINKLER[rain] if sprinkler else 1 - P_SPRINKLER[rain]
        p_wet = P_WET[(sprinkler, rain)]
        return p * (p_wet if wet else 1 - p_wet)
    
    
    numerator = sum(joint(True, s, True) for s in (True, False))
    evidence = sum(joint(r, s, True) for r in (True, False) for s in (True, False))
    print(round(numerator / evidence, 4), round(evidence, 4))
  92. 92

    Evaluating classifiers: accuracy, precision, recall, F1

    Build a confusion matrix and see why accuracy alone can be misleading for rare classes.

    A screening test misses 20 of 100 sick patients and falsely flags 30 of 900 healthy ones. What is the recall?

    Reveal answer and reasoning

    Recall = tp / (tp + fn) = 80 / 100 = 0.8.

    Show starting example
    Python
    def confusion(actual, predicted):
        tp = sum(a == 1 and p == 1 for a, p in zip(actual, predicted))
        fp = sum(a == 0 and p == 1 for a, p in zip(actual, predicted))
        fn = sum(a == 1 and p == 0 for a, p in zip(actual, predicted))
        tn = sum(a == 0 and p == 0 for a, p in zip(actual, predicted))
        return tp, fp, fn, tn
    
    
    def metrics(actual, predicted):
        tp, fp, fn, tn = confusion(actual, predicted)
        accuracy = (tp + tn) / len(actual)
        precision = tp / (tp + fp) if tp + fp else 0.0
        recall = tp / (tp + fn) if tp + fn else 0.0
        f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
        return accuracy, precision, recall, f1
    
    
    actual = [1, 1, 1, 1, 0, 0, 0, 0, 0, 0]
    predicted = [1, 1, 0, 0, 0, 0, 0, 0, 1, 0]
    tp, fp, fn, tn = confusion(actual, predicted)
    print(f"tp={tp} fp={fp} fn={fn} tn={tn}")
    print(*[round(value, 3) for value in metrics(actual, predicted)])
    
    rare = [1] * 10 + [0] * 990
    always_negative = [0] * 1000
    accuracy, precision, recall, f1 = metrics(rare, always_negative)
    print(accuracy, recall)
  93. 93

    Linear regression with gradient descent

    Fit a line by repeatedly stepping the parameters downhill on the squared error, and check it against the closed form.

    What happens if you set the learning rate to 0.5 in this program?

    Reveal answer and reasoning

    The steps are too large for the curvature of the loss, so w and b oscillate with growing size and diverge instead of converging to 2 and 1.

    Show starting example
    Python
    xs = [0, 1, 2, 3, 4]
    ys = [1, 3, 5, 7, 9]
    w = b = 0.0
    n = len(xs)
    for _ in range(2000):
        grad_w = sum(2 * (w * x + b - y) * x for x, y in zip(xs, ys)) / n
        grad_b = sum(2 * (w * x + b - y) for x, y in zip(xs, ys)) / n
        w -= 0.05 * grad_w
        b -= 0.05 * grad_b
    
    mean_x = sum(xs) / n
    mean_y = sum(ys) / n
    closed_w = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys)) / sum((x - mean_x) ** 2 for x in xs)
    closed_b = mean_y - closed_w * mean_x
    print(round(w, 3), round(b, 3))
    print(closed_w, closed_b)
  94. 94

    Logistic regression for classification

    Turn a linear score into a probability with the sigmoid and train it by gradient descent on log loss.

    Roughly what probability does the model give for x = 3.47?

    Reveal answer and reasoning

    About 0.5, because that is the decision boundary where w·x + b = 0 and σ(0) = 0.5.

    Show starting example
    Python
    import math
    
    xs = [1, 2, 3, 4, 5, 6]
    labels = [0, 0, 0, 1, 1, 1]
    
    
    def sigmoid(z):
        return 1 / (1 + math.exp(-z))
    
    
    w = b = 0.0
    for _ in range(3000):
        grad_w = sum((sigmoid(w * x + b) - y) * x for x, y in zip(xs, labels)) / len(xs)
        grad_b = sum((sigmoid(w * x + b) - y) for x, y in zip(xs, labels)) / len(xs)
        w -= 0.5 * grad_w
        b -= 0.5 * grad_b
    
    predictions = [1 if sigmoid(w * x + b) >= 0.5 else 0 for x in xs]
    print(predictions == labels)
    print(round(sigmoid(w * 2 + b), 3), round(sigmoid(w * 3.5 + b), 3), round(sigmoid(w * 5 + b), 3))
    print(round(-b / w, 2))
  95. 95

    k-nearest neighbors and feature scaling

    Classify by distance to stored examples, and see how an unscaled feature can dominate the result.

    Why does the answer change when the features are scaled?

    Reveal answer and reasoning

    Without scaling, the distance is determined almost entirely by the feature with the large numeric range. Scaling puts both features on a 0 to 1 range so each contributes fairly.

    Show starting example
    Python
    train = [((1.0, 1000), "A"), ((1.1, 3000), "A"), ((5.0, 1100), "B"), ((5.2, 3100), "B")]
    
    
    def make_scaler(points):
        lows = [min(p[i] for p in points) for i in range(2)]
        highs = [max(p[i] for p in points) for i in range(2)]
        return lambda p: tuple((p[i] - lows[i]) / (highs[i] - lows[i]) for i in range(2))
    
    
    def knn(train, query, k, scaled):
        scale = make_scaler([point for point, _ in train]) if scaled else (lambda p: p)
        q = scale(query)
        ordered = sorted(train, key=lambda item: sum((a - b) ** 2 for a, b in zip(scale(item[0]), q)))
        votes = [label for _, label in ordered[:k]]
        return max(set(votes), key=votes.count)
    
    
    print(knn(train, (1.2, 2000), 1, False))
    print(knn(train, (1.2, 2000), 1, True))
  96. 96

    Decision trees: entropy and information gain

    Choose the feature that best splits the data by measuring how much it reduces uncertainty.

    What is the entropy of a set with 3 positive and 1 negative example?

    Reveal answer and reasoning

    −(0.75·log₂ 0.75 + 0.25·log₂ 0.25) ≈ 0.811 bits.

    Show starting example
    Python
    import math
    
    rows = [
        ("sunny", False, "no"),
        ("sunny", True, "no"),
        ("overcast", False, "yes"),
        ("rain", False, "yes"),
        ("rain", False, "yes"),
        ("rain", True, "no"),
        ("overcast", True, "yes"),
        ("sunny", False, "no"),
    ]
    
    
    def entropy(labels):
        total = len(labels)
        return -sum((labels.count(v) / total) * math.log2(labels.count(v) / total) for v in set(labels))
    
    
    def gain(rows, index):
        remainder = 0.0
        for value in set(row[index] for row in rows):
            subset = [row[-1] for row in rows if row[index] == value]
            remainder += len(subset) / len(rows) * entropy(subset)
        return entropy([row[-1] for row in rows]) - remainder
    
    
    print(round(entropy([row[-1] for row in rows]), 3))
    gains = {"outlook": gain(rows, 0), "windy": gain(rows, 1)}
    print({name: round(value, 3) for name, value in gains.items()})
    print(max(gains, key=gains.get))
  97. 97

    k-means clustering

    Group unlabelled points by alternating between assigning points to centroids and moving the centroids.

    Which points end up in the first cluster?

    Reveal answer and reasoning

    (1.0, 1.0) and (1.5, 2.0). They are far closer to the centroid (1.25, 1.5) than to (3.9, 5.1).

    Show starting example
    Python
    points = [(1.0, 1.0), (1.5, 2.0), (3.0, 4.0), (5.0, 7.0), (3.5, 5.0), (4.5, 5.0), (3.5, 4.5)]
    
    
    def distance2(a, b):
        return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
    
    
    def kmeans(points, centroids):
        iterations = 0
        while True:
            iterations += 1
            clusters = [[] for _ in centroids]
            for point in points:
                nearest = min(range(len(centroids)), key=lambda i: distance2(point, centroids[i]))
                clusters[nearest].append(point)
            updated = [(sum(p[0] for p in c) / len(c), sum(p[1] for p in c) / len(c)) for c in clusters]
            if updated == centroids:
                return centroids, clusters, iterations
            centroids = updated
    
    
    centroids, clusters, iterations = kmeans(points, [points[0], points[3]])
    print([tuple(round(value, 2) for value in c) for c in centroids])
    print([len(c) for c in clusters], iterations)
  98. 98

    Overfitting and cross-validation

    Compare training accuracy with leave-one-out validation accuracy to choose k and detect memorization.

    Why is training accuracy always 1.0 for 1-nearest-neighbour when the stored points are unique?

    Reveal answer and reasoning

    Every training point's nearest neighbour is itself, at distance zero, so it always predicts its own label.

    Show starting example
    Python
    xs = list(range(1, 13))
    labels = [0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1]
    
    
    def predict(train_x, train_y, x, k):
        order = sorted(range(len(train_x)), key=lambda i: abs(train_x[i] - x))
        votes = [train_y[i] for i in order[:k]]
        return 1 if sum(votes) * 2 > len(votes) else 0
    
    
    def train_accuracy(k):
        return sum(predict(xs, labels, x, k) == y for x, y in zip(xs, labels)) / len(xs)
    
    
    def leave_one_out_accuracy(k):
        correct = 0
        for i in range(len(xs)):
            rest_x = xs[:i] + xs[i + 1:]
            rest_y = labels[:i] + labels[i + 1:]
            correct += predict(rest_x, rest_y, xs[i], k) == labels[i]
        return correct / len(xs)
    
    
    for k in (1, 3, 5):
        print(k, round(train_accuracy(k), 3), round(leave_one_out_accuracy(k), 3))
  99. 99

    The perceptron and linear separability

    Train a single artificial neuron with the perceptron rule, and see why it cannot learn XOR.

    Check that the learned weights classify (1, 0) correctly.

    Reveal answer and reasoning

    2·1 + 1·0 − 2 = 0, which is not greater than 0, so the output is 0, the correct AND value.

    Show starting example
    Python
    def train_perceptron(data, epochs=20):
        w = [0, 0]
        b = 0
        for _ in range(epochs):
            mistakes = 0
            for (x1, x2), target in data:
                output = 1 if w[0] * x1 + w[1] * x2 + b > 0 else 0
                error = target - output
                if error:
                    w[0] += error * x1
                    w[1] += error * x2
                    b += error
                    mistakes += 1
            if mistakes == 0:
                return w, b, True
        return w, b, False
    
    
    AND = [((0, 0), 0), ((0, 1), 0), ((1, 0), 0), ((1, 1), 1)]
    XOR = [((0, 0), 0), ((0, 1), 1), ((1, 0), 1), ((1, 1), 0)]
    print(train_perceptron(AND))
    print(train_perceptron(XOR)[2])
  100. 100

    Activation functions and softmax

    Compare sigmoid, tanh, and ReLU, and compute a numerically stable softmax.

    What does softmax([3, 3]) return?

    Reveal answer and reasoning

    [0.5, 0.5]. Equal scores give equal probabilities.

    Show starting example
    Python
    import math
    
    
    def sigmoid(z):
        return 1 / (1 + math.exp(-z))
    
    
    def relu(z):
        return max(0.0, z)
    
    
    def softmax(values):
        peak = max(values)
        exps = [math.exp(v - peak) for v in values]
        total = sum(exps)
        return [e / total for e in exps]
    
    
    for z in (-2, 0, 2):
        print(z, round(sigmoid(z), 3), round(math.tanh(z), 3), relu(z))
    probabilities = softmax([2.0, 1.0, 0.1])
    print([round(p, 3) for p in probabilities], round(sum(probabilities), 6))
    print([round(p, 3) for p in softmax([1000, 1001])])
  101. 101

    Multilayer networks: a forward pass that computes XOR

    Combine two layers of neurons with hand-chosen weights to compute XOR, which one neuron cannot.

    What does the network output for (1, 0), step by step?

    Reveal answer and reasoning

    Hidden: 1 + 0 − 0.5 = 0.5 gives 1, and 1 + 0 − 1.5 = −0.5 gives 0. Output: 1·1 − 1·0 − 0.5 = 0.5 gives 1.

    Show starting example
    Python
    def dense(inputs, weights, biases, activation):
        return [
            activation(sum(w * x for w, x in zip(row, inputs)) + bias)
            for row, bias in zip(weights, biases)
        ]
    
    
    def step(z):
        return 1 if z > 0 else 0
    
    
    hidden_weights = [[1, 1], [1, 1]]
    hidden_biases = [-0.5, -1.5]
    output_weights = [[1, -1]]
    output_biases = [-0.5]
    
    for x in ((0, 0), (0, 1), (1, 0), (1, 1)):
        hidden = dense(x, hidden_weights, hidden_biases, step)
        output = dense(hidden, output_weights, output_biases, step)[0]
        print(x, hidden, output)
  102. 102

    Backpropagation: learning XOR by gradient descent

    Compute gradients with the chain rule, verify them numerically, and train a small network on XOR.

    Why is the gradient check run with centred differences instead of a one-sided difference?

    Reveal answer and reasoning

    The central difference (L(w + ε) − L(w − ε)) / 2ε has error proportional to ε², while the one-sided version has error proportional to ε, so it is far more accurate for the same step.

    Show starting example
    Python
    import math
    
    DATA = [((0, 0), 0), ((0, 1), 1), ((1, 0), 1), ((1, 1), 0)]
    HIDDEN = 3
    LEARNING_RATE = 1.0
    w1 = [[0.5, -0.4], [-0.3, 0.8], [0.9, 0.2]]
    b1 = [0.1, -0.1, 0.05]
    w2 = [0.7, -0.6, 0.4]
    b2 = [0.0]
    
    
    def sigmoid(z):
        return 1 / (1 + math.exp(-z))
    
    
    def forward(x):
        h = [sigmoid(sum(w1[j][k] * x[k] for k in range(2)) + b1[j]) for j in range(HIDDEN)]
        y = sigmoid(sum(w2[j] * h[j] for j in range(HIDDEN)) + b2[0])
        return h, y
    
    
    def loss():
        return sum(0.5 * (forward(x)[1] - t) ** 2 for x, t in DATA)
    
    
    def gradients():
        gw1 = [[0.0, 0.0] for _ in range(HIDDEN)]
        gb1 = [0.0] * HIDDEN
        gw2 = [0.0] * HIDDEN
        gb2 = 0.0
        for x, t in DATA:
            h, y = forward(x)
            dz2 = (y - t) * y * (1 - y)
            gb2 += dz2
            for j in range(HIDDEN):
                gw2[j] += dz2 * h[j]
                dz1 = dz2 * w2[j] * h[j] * (1 - h[j])
                gb1[j] += dz1
                for k in range(2):
                    gw1[j][k] += dz1 * x[k]
        return gw1, gb1, gw2, gb2
    
    
    analytic = gradients()[0][1][0]
    eps = 1e-6
    w1[1][0] += eps
    up = loss()
    w1[1][0] -= 2 * eps
    down = loss()
    w1[1][0] += eps
    print(abs((up - down) / (2 * eps) - analytic) < 1e-8)
    
    for _ in range(10000):
        gw1, gb1, gw2, gb2 = gradients()
        for j in range(HIDDEN):
            for k in range(2):
                w1[j][k] -= LEARNING_RATE * gw1[j][k]
            b1[j] -= LEARNING_RATE * gb1[j]
            w2[j] -= LEARNING_RATE * gw2[j]
        b2[0] -= LEARNING_RATE * gb2
    
    print(round(loss(), 4))
    print([round(forward(x)[1], 2) for x, _ in DATA])
  103. 103

    Cross-entropy loss and the softmax gradient

    See why classification networks use cross-entropy, and verify its simple gradient numerically.

    What is the loss if the model gives the correct class probability 0.5?

    Reveal answer and reasoning

    −ln 0.5 ≈ 0.693. The loss is the negative natural logarithm of the probability given to the correct class.

    Show starting example
    Python
    import math
    
    
    def softmax(values):
        peak = max(values)
        exps = [math.exp(v - peak) for v in values]
        total = sum(exps)
        return [e / total for e in exps]
    
    
    def cross_entropy(scores, target):
        return -math.log(softmax(scores)[target])
    
    
    def sigmoid(z):
        return 1 / (1 + math.exp(-z))
    
    
    scores = [2.0, 1.0, 0.1]
    target = 0
    p = softmax(scores)
    analytic = [p[i] - (1 if i == target else 0) for i in range(3)]
    numeric = []
    for i in range(3):
        up = scores[:]
        up[i] += 1e-6
        down = scores[:]
        down[i] -= 1e-6
        numeric.append((cross_entropy(up, target) - cross_entropy(down, target)) / 2e-6)
    print(all(abs(a - n) < 1e-6 for a, n in zip(analytic, numeric)))
    print(round(cross_entropy(scores, 0), 4), round(cross_entropy(scores, 2), 4))
    y = sigmoid(-6)
    print(round((y - 1) * y * (1 - y), 4), round(y - 1, 4))
  104. 104

    Convolutional networks: output shapes and parameter counts

    Compute how convolution and pooling change the size of an image, and why sharing weights saves parameters.

    What output size does a 3 × 3 filter with padding 1 and stride 1 give for a 32 × 32 input?

    Reveal answer and reasoning

    (32 + 2·1 − 3) / 1 + 1 = 32. Padding of 1 with a 3 × 3 filter preserves the size.

    Show starting example
    Python
    def conv_out(n, k, stride=1, pad=0):
        return (n + 2 * pad - k) // stride + 1
    
    
    size, channels = 28, 1
    size = conv_out(size, 5)
    conv1_params = (5 * 5 * channels + 1) * 6
    channels = 6
    print(size, channels, conv1_params)
    size = conv_out(size, 2, stride=2)
    print(size, channels)
    size = conv_out(size, 5)
    conv2_params = (5 * 5 * channels + 1) * 16
    channels = 16
    print(size, channels, conv2_params)
    size = conv_out(size, 2, stride=2)
    print(size, channels, size * size * channels)
    print((28 * 28) * (24 * 24 * 6) + 24 * 24 * 6)
    print(256 * 120 + 120)
  105. 105

    Regularization: weight decay and dropout

    Shrink weights with an L2 penalty, and see why inverted dropout keeps the expected activation unchanged.

    What does the closed form give for λ = 0?

    Reveal answer and reasoning

    w = 60 / 30 = 2, the unpenalized least-squares slope.

    Show starting example
    Python
    from itertools import product
    
    activations = [0.2, 0.8, 0.5, 1.0]
    keep = 0.5
    masks = list(product([0, 1], repeat=len(activations)))
    mean = [sum(a * mask[i] / keep for mask in masks) / len(masks) for i, a in enumerate(activations)]
    print([round(value, 10) for value in mean] == activations, len(masks))
    
    xs = [1, 2, 3, 4]
    ys = [2, 4, 6, 8]
    
    
    def fit(penalty):
        w = 0.0
        n = len(xs)
        for _ in range(5000):
            grad = sum(2 * (w * x - y) * x for x, y in zip(xs, ys)) / n + 2 * penalty * w
            w -= 0.02 * grad
        return w
    
    
    print(round(fit(0.0), 3), round(fit(1.0), 3))
    closed = sum(x * y for x, y in zip(xs, ys)) / (sum(x * x for x in xs) + len(xs) * 1.0)
    print(round(closed, 3))
  106. 106

    Images as arrays of pixels

    Represent a grayscale image as rows of numbers, convert color to gray, and adjust brightness with clipping.

    What does brighten(image, -60) return?

    Reveal answer and reasoning

    [[0, 0, 40], [90, 140, 190]]. Negative results are clipped to 0.

    Show starting example
    Python
    image = [
        [0, 50, 100],
        [150, 200, 250],
    ]
    
    
    def to_gray(pixel):
        red, green, blue = pixel
        return round(0.299 * red + 0.587 * green + 0.114 * blue)
    
    
    def brighten(img, amount):
        return [[min(255, max(0, value + amount)) for value in row] for row in img]
    
    
    def normalize(img):
        return [[value / 255 for value in row] for row in img]
    
    
    print(len(image), len(image[0]))
    print(to_gray((200, 100, 50)), to_gray((255, 255, 255)))
    print(brighten(image, 100))
    print([[round(value, 2) for value in row] for row in normalize(image)])
    print(image[1][2])
  107. 107

    Filtering images with convolution

    Slide a small kernel over an image to blur it, and compute the valid output size.

    What size is the output for a 100 × 80 image and a 5 × 5 kernel with valid filtering?

    Reveal answer and reasoning

    (100 − 5 + 1) × (80 − 5 + 1) = 96 × 76.

    Show starting example
    Python
    def convolve(image, kernel):
        kh, kw = len(kernel), len(kernel[0])
        output = []
        for r in range(len(image) - kh + 1):
            row = []
            for c in range(len(image[0]) - kw + 1):
                row.append(sum(image[r + i][c + j] * kernel[i][j] for i in range(kh) for j in range(kw)))
            output.append(row)
        return output
    
    
    image = [[0, 0, 90, 90, 90] for _ in range(5)]
    box = [[1 / 9] * 3 for _ in range(3)]
    identity = [[0, 0, 0], [0, 1, 0], [0, 0, 0]]
    blurred = convolve(image, box)
    print(len(blurred), len(blurred[0]))
    print([round(value, 1) for value in blurred[0]])
    print(convolve(image, identity)[0])
  108. 108

    Edge detection with the Sobel operator

    Estimate intensity gradients with two kernels and combine them into edge strength and direction.

    What magnitude would a perfectly flat image of constant value 50 give?

    Reveal answer and reasoning

    0 everywhere, because both Sobel kernels sum to zero and there is no intensity change.

    Show starting example
    Python
    import math
    
    
    def convolve(image, kernel):
        kh, kw = len(kernel), len(kernel[0])
        return [
            [
                sum(image[r + i][c + j] * kernel[i][j] for i in range(kh) for j in range(kw))
                for c in range(len(image[0]) - kw + 1)
            ]
            for r in range(len(image) - kh + 1)
        ]
    
    
    SOBEL_X = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]
    SOBEL_Y = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]
    
    vertical_edge = [[0, 0, 100, 100, 100] for _ in range(5)]
    gx = convolve(vertical_edge, SOBEL_X)
    gy = convolve(vertical_edge, SOBEL_Y)
    magnitude = [[round(math.hypot(a, b)) for a, b in zip(row_x, row_y)] for row_x, row_y in zip(gx, gy)]
    print(magnitude[1])
    print(round(math.degrees(math.atan2(gy[1][0], gx[1][0]))))
    
    horizontal_edge = [[0] * 5, [0] * 5, [100] * 5, [100] * 5, [100] * 5]
    print(convolve(horizontal_edge, SOBEL_Y)[0])
  109. 109

    Thresholding with Otsu's method

    Pick a threshold automatically by maximizing the separation between dark and bright pixel groups.

    Would a threshold of 100 give the same binary image here?

    Reveal answer and reasoning

    Yes. Every dark pixel is at most 40 and every bright pixel is at least 200, so any threshold from 40 to 199 yields the same result.

    Show starting example
    Python
    def otsu(pixels):
        histogram = [0] * 256
        for pixel in pixels:
            histogram[pixel] += 1
        total = len(pixels)
        total_sum = sum(level * count for level, count in enumerate(histogram))
        best_variance, best_threshold = -1.0, 0
        weight_dark = sum_dark = 0
        for t in range(256):
            weight_dark += histogram[t]
            if weight_dark == 0:
                continue
            weight_bright = total - weight_dark
            if weight_bright == 0:
                break
            sum_dark += t * histogram[t]
            mean_dark = sum_dark / weight_dark
            mean_bright = (total_sum - sum_dark) / weight_bright
            between = weight_dark * weight_bright * (mean_dark - mean_bright) ** 2
            if between > best_variance:
                best_variance, best_threshold = between, t
        return best_threshold
    
    
    pixels = [28, 30, 35, 40, 33, 31, 38, 29, 205, 210, 215, 200, 220, 208, 212, 218]
    threshold = otsu(pixels)
    print(threshold)
    print([1 if pixel > threshold else 0 for pixel in pixels])
  110. 110

    Connected-component labeling

    Find and number separate blobs in a binary image with a flood fill, and measure their sizes.

    How many components would 8-connectivity find in this image?

    Reveal answer and reasoning

    Three. No two blobs touch diagonally here, so the labelling is the same as with 4-connectivity.

    Show starting example
    Python
    from collections import deque
    
    GRID = [
        "11000",
        "11001",
        "00001",
        "10100",
        "11100",
    ]
    
    
    def label_components(grid):
        rows, cols = len(grid), len(grid[0])
        labels = [[0] * cols for _ in range(rows)]
        count = 0
        sizes = []
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == "1" and labels[r][c] == 0:
                    count += 1
                    size = 0
                    queue = deque([(r, c)])
                    labels[r][c] = count
                    while queue:
                        y, x = queue.popleft()
                        size += 1
                        for ny, nx in ((y + 1, x), (y - 1, x), (y, x + 1), (y, x - 1)):
                            if 0 <= ny < rows and 0 <= nx < cols and grid[ny][nx] == "1" and labels[ny][nx] == 0:
                                labels[ny][nx] = count
                                queue.append((ny, nx))
                    sizes.append(size)
        return count, sizes, labels
    
    
    count, sizes, labels = label_components(GRID)
    print(count, sizes)
    for row in labels:
        print(*row)
  111. 111

    Template matching with sum of squared differences

    Find where a small pattern occurs in an image by sliding it over every position and scoring the match.

    What would the best score be if the template's last pixel were 55 instead of 50 in the image?

    Reveal answer and reasoning

    The best position stays (2, 3) but its score becomes (50 − 55)² = 25 instead of 0, because exactly one pixel now differs by 5.

    Show starting example
    Python
    image = [
        [10, 10, 10, 10, 10, 10],
        [10, 10, 10, 10, 10, 10],
        [10, 10, 10, 80, 90, 10],
        [10, 10, 10, 70, 200, 10],
        [10, 10, 10, 60, 50, 10],
        [10, 10, 10, 10, 10, 10],
    ]
    template = [[80, 90], [70, 200], [60, 50]]
    
    
    def ssd_map(image, template):
        th, tw = len(template), len(template[0])
        scores = {}
        for r in range(len(image) - th + 1):
            for c in range(len(image[0]) - tw + 1):
                scores[(r, c)] = sum((image[r + i][c + j] - template[i][j]) ** 2 for i in range(th) for j in range(tw))
        return scores
    
    
    scores = ssd_map(image, template)
    best = min(scores, key=scores.get)
    print(best, scores[best])
    print(sorted(scores.values())[1] > 0)
    print(len(scores))