Complexity, Exceptions and Testing
Every program so far assumed valid input, clean data and small sizes. This lecture handles the real world, where a program lets you down in three ways: too slow, it crashes, or it is quietly wrong.
By the end of this lecture you should be able to
- Describe growth rates with big-O notation and state the cost of common operations and containers
- Handle failures with try / except / else / finally, and know when not to catch
- Distinguish an exception from a bug, and use assert correctly
- Write tests with pytest, and understand how the lab platform marks your work
- State what separates the five prime-counting methods, and which size each one suits
Contents
Three ways a program lets you down
Until now your programs have lived in an ideal world: the input is always valid, the data is always clean, the size is always small.
The real world is not like that. Programs disappoint in three ways:
| Way | Symptom | This lecture’s answer |
|---|---|---|
| too slow | fine on small data, never finishes on real data | complexity analysis |
| it crashes | unexpected input terminates it | exception handling |
| quietly wrong | no error, but the answer is incorrect | assertions and tests |
The three look unrelated but are one question wearing three hats: how do you know your program actually works?
I. Too slow: complexity
Why not just use a stopwatch
The obvious way to find out whether an algorithm is fast is to time it:
import time
def sum_to(n: int) -> int:
total = 0
for i in range(n):
total += i
return total
for n in [10_000, 100_000, 1_000_000]:
t = time.perf_counter()
sum_to(n)
print(f"n = {n:>9,} took {time.perf_counter() - t:.4f} s")
That number is useful, but it cannot compare algorithms, because it depends on the machine, what the CPU is doing at that moment, the Python version, and whether you have other programs open.
We need a measure that is independent of the machine.
Count steps, not seconds
Instead: count how many basic operations the algorithm performs. A basic operation is arithmetic, a comparison, an assignment, an index — anything that takes one step.
def sum_to(n):
total = 0 # 1 step
for i in range(n): # n iterations
total += i # 1 step each
return total # 1 step
About n + 2 steps.
Another:
def has_duplicate(items):
for i in range(len(items)): # n times
for j in range(len(items)): # n times each
if items[i] == items[j]: # 1 step
...
That is n × n = n² steps.
The essential difference is how they grow as n increases.
| n | n + 2 | n² |
|---|---|---|
| 10 | 12 | 100 |
| 100 | 102 | 10,000 |
| 1,000 | 1,002 | 1,000,000 |
| 1,000,000 | 1,000,002 | 1,000,000,000,000 |
Multiply n by ten and the first grows tenfold; the second grows a hundredfold.
Big-O notation
Since only the trend at large n matters:
- keep the fastest-growing term:
n² + 3n + 5→n² - drop constant factors:
5nand100nare bothn
Written O(...):
n + 2→ O(n), linearn²→ O(n²), quadratic3→ O(1), constant (independent of n)
Why can the constants go? Because once the order differs, no constant can compensate:
for n in [10, 100, 1000, 10000]:
linear = 100 * n
quadratic = n * n
winner = "O(n) wins" if linear < quadratic else "O(n^2) wins"
print(f"n={n:>6} 100n = {linear:>10,} n^2 = {quadratic:>12,} {winner}")
Past n = 100 the O(n) version never loses again. That is why the order matters more than the constant.
The common orders
Fastest to slowest:
| Notation | Name | Example | Steps at n=1000 |
|---|---|---|---|
| O(1) | constant | a[i], a dict lookup | 1 |
| O(log n) | logarithmic | binary search | 10 |
| O(n) | linear | walking a list, x in list | 1,000 |
| O(n log n) | linearithmic | merge sort, sorted() | 10,000 |
| O(n²) | quadratic | a nested loop | 1,000,000 |
| O(2ⁿ) | exponential | naive recursive Fibonacci | astronomical |
import math
print(f"{'n':>8} {'log n':>7} {'n':>8} {'n log n':>11} {'n^2':>14}")
for n in [10, 1000, 100000]:
print(f"{n:>8} {math.log2(n):>7.1f} {n:>8,} {n*n*math.log2(n):>11,.0f} {n*n:>14,}")
The cost of Python’s operations
This table is worth memorizing:
| Operation | Complexity | Note |
|---|---|---|
a[i] | O(1) | indexing a list, tuple or string |
a.append(x) | O(1) | adding at the end |
a.pop() | O(1) | removing from the end |
a.insert(0, x) | O(n) | everything after shifts |
a.pop(0) | O(n) | likewise |
x in a (list) | O(n) | scans from the front |
x in s (set/dict) | O(1) | hashed |
a.sort() / sorted(a) | O(n log n) | |
d[k] | O(1) | dictionary lookup |
len(a) | O(1) | the length is stored, not counted |
a + b (lists) | O(n+m) | builds a new list |
a[i:j] (slice) | O(j−i) | builds a new list |
s1 + s2 (strings) | O(n+m) | strings are immutable; a new one each time |
This is behind L6’s claim that lists search slowly and sets search fast:
import time
n = 30_000
data_list = list(range(n))
data_set = set(data_list)
t = time.perf_counter()
for x in range(0, n, 100):
x in data_list
list_time = time.perf_counter() - t
t = time.perf_counter()
for x in range(0, n, 100):
x in data_set
set_time = time.perf_counter() - t
print(f"list in: {list_time:.4f} s")
print(f"set in: {set_time:.6f} s")
print(f"ratio: {list_time / set_time:,.0f}")
Double the length of the list and the search time roughly quadruples; the set barely moves. This time the conclusion has data behind it.
Is concatenating in a loop actually slow?
Lab 4’s triangle problem, and a great many textbooks, say that s += w in a loop is O(n²) and you should use join. That claim needs a qualifier.
Measure it first:
import time
words = ["hello"] * 80_000
t = time.perf_counter()
s = ""
for w in words:
s += w
plain = time.perf_counter() - t
t = time.perf_counter()
keep = []
for w in words:
keep.append(keep[-1] + w if keep else w)
kept = time.perf_counter() - t
t = time.perf_counter()
s2 = "".join(words)
joined = time.perf_counter() - t
print(f"plain s += w: {plain:.5f} s")
print(f"keeping every prefix:{kept:.4f} s")
print(f"join: {joined:.6f} s")
print(f"same result: {s == s2 == keep[-1]}")
What comes out is that the plain s += w loop and join land in the same range, while the version that keeps every intermediate result is three orders of magnitude slower.
The reason is a CPython implementation detail: when a string has exactly one reference, s += w can extend it in place instead of copying the whole old string each time. The moment an intermediate result is referenced by another name or held in a container, that optimization no longer applies, every step has to build a fresh string and copy the old contents, and the total cost degrades to quadratic.
Write the conclusion as “it depends on whether other references exist”, not as “concatenation in a loop is always quadratic”. In your own code, still prefer join: it does not rely on that implementation detail, and it is linear under every implementation and every reference pattern.
Why binary search is O(log n)
L7 wrote binary search and deferred the complexity to here.
Each comparison halves the range: n → n/2 → n/4 → … → 1.
The question is: how many halvings take n down to 1? That is log₂ n.
import math
for n in [100, 1_000, 1_000_000, 1_000_000_000]:
print(f"{n:>15,} elements need at most {math.ceil(math.log2(n)):>2} comparisons")
A billion elements, thirty comparisons. That is what logarithmic complexity buys, and why sorted data is so valuable.
Space complexity
The same notation describes memory.
# O(1) space: a few variables regardless of n
def sum_loop(n: int) -> int:
total = 0
for i in range(n):
total += i
return total
# O(n) space: builds a list of length n
def sum_list(n: int) -> int:
return sum([i for i in range(n)])
print(sum_loop(1000), sum_list(1000))
L13 shows how generators bring O(n) space back down to O(1).
How to improve complexity
In order of effectiveness:
- change the algorithm: O(n²) to O(n log n) is a change of order
- change the data structure: list lookup to set lookup is O(n) to O(1)
- avoid recomputation: memoization, which section IV develops
- use built-ins:
sum(),sorted(),"".join()are implemented in C and run an order of magnitude faster than a Python loop
import time
n = 2_000_000
t = time.perf_counter()
total1 = 0
for i in range(n):
total1 += i
loop_t = time.perf_counter() - t
t = time.perf_counter()
total2 = sum(range(n))
builtin_t = time.perf_counter() - t
print(f"hand-written loop: {loop_t:.4f} s")
print(f"built-in sum: {builtin_t:.4f} s")
print(f"{loop_t / builtin_t:.1f}x faster (both O(n); the difference is the constant)")
II. It crashes: exceptions
Syntactically valid, still fails
x = 10
y = 0
print(x / y)
That code is entirely valid and compiled without complaint, but failed at run time. An error that occurs during execution is an exception.
Left unhandled, it terminates the program — not one further line runs. The last line of the traceback gives the exception type and message; the lines above it point at where things went wrong.
Two more common runtime failures:
value = 4
print(value + spam * 3)
text = "2"
print(text + 2)
NameError says a name was never defined; TypeError says two types cannot be combined that way. What they share is that neither names nor types can be checked ahead of time — only actually executing that line reveals the problem.
try / except
def safe_divide(a: float, b: float) -> float | None:
try:
return a / b
except ZeroDivisionError:
print("cannot divide by zero")
return None
print(safe_divide(10, 2))
print(safe_divide(10, 0))
print("the program continues")
The try body runs normally; the moment an exception is raised, control jumps to the matching except.
Catch specific exceptions
def parse_age(text: str) -> int | None:
try:
return int(text)
except ValueError:
print(f"'{text}' is not an integer")
return None
for s in ["18", "abc", "3.14", ""]:
print(s, "->", parse_age(s))
This closes the loop from L3, where non-numeric input crashed the program and I said L11 would fix it.
Several except clauses
def get_item(data: dict[str, list[int]], key: str, index: int) -> int | None:
try:
return data[key][index]
except KeyError:
print(f"no key {key}")
except IndexError:
print(f"index {index} out of range")
except TypeError as error:
print(f"wrong type: {error}")
return None
d = {"a": [1, 2, 3]}
print(get_item(d, "a", 1))
print(get_item(d, "b", 0))
print(get_item(d, "a", 10))
At most one clause runs — the first one, top to bottom, that matches.
else and finally
def divide(a: float, b: float) -> None:
try:
result = a / b
except ZeroDivisionError:
print(" failed")
else:
print(f" succeeded, result {result}") # only when nothing was raised
finally:
print(" cleanup") # always
print("10 / 2:")
divide(10, 2)
print("10 / 0:")
divide(10, 0)
elseruns when thetrybody raised nothing. Put “things to do only on success” herefinallyruns regardless. Put cleanup here — closing files, dropping connections
finally even beats return:
def f() -> str:
try:
return "returned from try"
finally:
print(" finally ran anyway")
print(f())
raise: throwing your own
def set_age(age: int) -> int:
if age < 0:
raise ValueError(f"age cannot be negative: {age}")
if age > 150:
raise ValueError(f"implausible age: {age}")
return age
print(set_age(18))
print(set_age(-5))
When should you raise rather than return None?
When the caller must not be allowed to ignore the problem. A returned None is easy to overlook, and the error then surfaces far from its cause. An exception stops immediately, and the traceback points at where it went wrong.
Custom exceptions
class InsufficientFunds(Exception):
"""Not enough money in the account."""
def __init__(self, balance: float, amount: float) -> None:
super().__init__(f"balance {balance}, cannot withdraw {amount}")
self.balance = balance
self.amount = amount
def withdraw(balance: float, amount: float) -> float:
if amount > balance:
raise InsufficientFunds(balance, amount)
return balance - amount
try:
withdraw(100, 500)
except InsufficientFunds as error:
print("caught:", error)
print("short by:", error.amount - error.balance)
Custom exceptions inherit from Exception — L10’s inheritance earning its keep. The benefit is that a caller can catch precisely this failure rather than every ValueError.
with: automatic cleanup
# the manual version, easy to forget
handle = open("demo.txt", "w", encoding="utf-8")
handle.write("hello")
handle.close()
# the with version, closed automatically
with open("demo.txt", "w", encoding="utf-8") as handle:
handle.write("hello")
print("closed after the block:", handle.closed)
# an exception inside the block still closes it
try:
with open("demo.txt", encoding="utf-8") as reader:
raise ValueError("body failed")
except ValueError:
print("closed after the exception too:", reader.closed)
with guarantees cleanup on exit — equivalent to a correctly written try...finally. Only when __enter__ returns successfully is __exit__ called at all. Files in full are L12.
Check first, or apologize after
Two styles:
d = {"a": 1}
# style one: Look Before You Leap
if "b" in d:
print(d["b"])
else:
print("LBYL: no such key")
# style two: Easier to Ask Forgiveness than Permission
try:
print(d["b"])
except KeyError:
print("EAFP: no such key")
Python prefers the second (EAFP), because:
- the normal path costs nothing extra (exceptions only cost when raised)
- it avoids the state changing between the check and the use
- the main line of the code stays clear, with failure handling to one side
But write the simple check when it is simple. if not items: return is far clearer than wrapping things in a try.
III. Quietly wrong: assertions and tests
assert: checking what you believe must hold
def average(nums: list[float]) -> float:
assert len(nums) > 0, "average does not accept an empty list"
return sum(nums) / len(nums)
print(average([1, 2, 3]))
assert condition, message says: “I believe this must be true here. If it is not, my program has a bug — stop immediately.”
Assertions disappear in optimized mode
print("__debug__ =", __debug__)
print("assert statements:", "enabled" if __debug__ else "removed")
Under normal mode __debug__ is True and assertions run. With the -O flag:
python3 -O myscript.py # __debug__ becomes False; every assert vanishes
Measured on the average above: in normal mode an empty list raises AssertionError; under -O the assertion is skipped, execution reaches sum([]) / 0, and what comes out is a ZeroDivisionError.
So assertions cannot validate user input. Validating input needs if plus raise, a path that optimization never removes.
Assertions are not exception handling
The most-confused point in the lecture. Keep them apart:
| Exceptions (try/except) | Assertions (assert) | |
|---|---|---|
| for | expected external problems | impossible internal states |
| examples | missing file, bad user input, dropped network | a function received an impossible argument |
| whose fault | the environment or the user | the programmer |
| should you catch it | yes | no |
| in production | keep them | can be switched off |
Assertions as executable documentation
def binary_search(sorted_nums: list[int], target: int) -> int:
assert sorted_nums == sorted(sorted_nums), "the input must be sorted"
lo, hi = 0, len(sorted_nums) - 1
while lo <= hi:
mid = (lo + hi) // 2
if sorted_nums[mid] == target:
return mid
if sorted_nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
print(binary_search([1, 3, 5, 7], 5))
print(binary_search([1, 3, 5, 7], 4))
That assertion turns the precondition “the input must be sorted” into an executable check. Better than a comment — comments go stale, assertions do not.
A postcondition can be written the same way: the index returned really does hold the target, or −1 was returned and the target really is absent.
pytest: making testing routine
L8 established that tests, not plausibility, decide whether code is correct. We hand-wrote a checking loop there. Here is the standard tool.
Install:
python3 -m pip install pytest
The rules are simple:
- files named
test_* - functions named
test_* - express expectations with
assert - use
pytest.raiseswhen you expect a failure
Given mymath.py:
# mymath.py
def add(a: int, b: int) -> int:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("cannot divide by zero")
return a / b
the test file:
# test_mymath.py
import pytest
from mymath import add, divide
def test_add_two_positive():
assert add(2, 3) == 5
def test_add_negatives():
assert add(-1, -1) == -2
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError): # we expect it to raise
divide(10, 0)
The last case is deliberately wrong: divide raises ValueError while the test expects ZeroDivisionError. That is what makes it worth reading what pytest prints on failure. The real run:
$ python3 -m pytest -q
.F.. [100%]
=================================== FAILURES ===================================
_____________________________ test_divide_by_zero ______________________________
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
> divide(10, 0)
test_mymath.py:17:
def divide(a: float, b: float) -> float:
if b == 0:
> raise ValueError("cannot divide by zero")
E ValueError: cannot divide by zero
mymath.py:6: ValueError
=========================== short test summary info ============================
FAILED test_mymath.py::test_divide_by_zero - ValueError: cannot divide by zero
1 failed, 3 passed in 0.01s
Three things come out of that, and you need all three: which test failed, what it expected (ZeroDivisionError), and what actually happened (ValueError). That is what pytest gives you over a hand-written checking loop.
The usual options:
python3 -m pytest # run everything
python3 -m pytest -v # name each test
python3 -m pytest -k divide # only those matching "divide"
What to test
Recalling L8’s edge list, a function deserves at minimum:
- the normal case: typical input
- empty input: empty list, empty string
- a single element
- boundaries: zero, negatives, largest and smallest
- failure: does invalid input raise as it should
def clamp(x: float, lo: float, hi: float) -> float:
"""Constrain x to [lo, hi]."""
if lo > hi:
raise ValueError("lo must not exceed hi")
return max(lo, min(x, hi))
checks = [
("inside", clamp(5, 0, 10), 5),
("below", clamp(-3, 0, 10), 0),
("above", clamp(99, 0, 10), 10),
("on the bound", clamp(0, 0, 10), 0),
("equal bounds", clamp(7, 3, 3), 3),
]
for name, got, expected in checks:
print(f" {'PASS' if got == expected else 'FAIL'} {name}: {got}")
try:
clamp(5, 10, 0)
print(" FAIL: no exception raised")
except ValueError:
print(" PASS: ValueError raised for an impossible range")
Testing code you did not write
This week’s lab hands you a module that is “already written”. Your job is not to change it, it is to write tests from what its documentation promises:
# textstats.py
def word_count(text: str) -> int:
"""Count the words."""
return len(text.split())
def longest_word(text: str) -> str:
"""Return the longest word. On a tie, the first one."""
words = text.split()
return max(words, key=len)
def average_word_length(text: str) -> float:
"""Return the mean word length."""
words = text.split()
return sum(len(w) for w in words) / len(words)
All three come with a docstring, and none of them says what happens on an empty string. Measured:
word_count("") -> 0
longest_word("") -> ValueError
average_word_length("") -> ZeroDivisionError
Two of the three fail on an empty string: max() on an empty sequence raises ValueError, and dividing by zero raises ZeroDivisionError.
The inputs the documentation never pins down are exactly where things go wrong. You did not write this module, so the docstrings are your only guide — and the purpose of a test is to write the expectation down first and then see whether the implementation meets it.
How the platform marks your work
IV. Five methods and memoization
What the five prime methods cost
This week’s solo assignment implements five ways to count primes over the same range. Their complexities:
| Method | Complexity | Space | Note |
|---|---|---|---|
| brute trial division | O(n²) | O(1) | every p tries every d up to p−1 |
| square-root pruning | O(n√n) | O(1) | only trial-divide up to √p |
| 6k±1 pruning | O(n√n) | O(1) | skip multiples of 2 and 3; smaller constant |
| sieve of Eratosthenes | O(n log log n) | O(n) | computes the whole range at once |
| Miller–Rabin | O(n log³ n) | O(1) | one number at a time, a few modular powers each |
The first three share an order and differ in constants; the sieve drops the order but pays for an array of length n.
Complexity describes the shape of growth; the clock also depends on the constant. Measured on the build machine at n = 100000, taking the best of several runs:
| Method | n = 2000 | n = 100000 |
|---|---|---|
| brute trial division | 0.0042 s | too slow; use a smaller size |
| square-root pruning | 0.00047 s | 0.0864 s |
| 6k±1 pruning | 0.00019 s | 0.0288 s |
| sieve | 0.000036 s | 0.00212 s |
| Miller–Rabin | 0.00046 s | 0.0267 s |
(All five return 303 at n = 2000 and 9592 at n = 100000. The counts must agree exactly — that is what “verify correctness before timing” means.)
Square-root pruning and 6k±1 are the same order with constants about three apart; the sieve is fastest throughout and pays for it with that O(n) array. The sieve earns its keep only when you want the count for a whole range; to test whether one large number is prime, Miller–Rabin’s O(1) space and O(log³ n) cost fit better.
Choosing among them
- many numbers over a modest range: the sieve
- one very large number: Miller–Rabin
- this week’s task (n ≤ 100000): any of the three trial divisions finishes; they differ in seconds, not in order
Memoization: exponential down to linear
L7 found that naive Fibonacci needs a quarter of a million calls, and promised a fix here.
First, how bad is the recomputation really:
fib(20) = 6765 calls = 21,891
fib(25) = 75025 calls = 242,785
fib(28) = 317811 calls = 1,028,457
Raising n by 3 multiplies the call count by about 4 while the answer grows by about 3. The cause is the same subproblem being computed over and over.
The remedy is direct: remember what you have already computed.
fib(28) = 317811, calls = 55, cache entries = 27
Same answer, call count down from 1,028,457 to 55, and only 27 entries in the cache — because fib has only 28 distinct subproblems.
Complexity falls from O(2ⁿ) to O(n), since each n is computed once. The price is storing n results: another instance of trading space for time.
The cache key must identify the subproblem
Carrying the same trick over to the coin problem from L7 is easy to get wrong:
key = amount: 129
key = (amount, index): 11
The correct answer is 11; keying on amount alone gives 129.
The reason: ways(amount, coins, index) has two arguments that change. Looking only at amount merges “make 10 using the first two denominations” with “make 10 using all four”, so the cache stores a value that belongs to a different subproblem.
Two conditions make a cache correct:
- the key identifies the subproblem uniquely — every varying argument goes into it
- the function returns the same output for the same input — a function depending on the clock, on randomness or on outside state cannot be cached
The corrected version:
ways(100) = 2156, calls = 801, cache entries = 400
With the key written as the tuple (amount, index), the 400 entries are exactly the valid combinations of the two.
functools.lru_cache
The standard library ships the decorator (L14 explains what a decorator is):
fib(10) = 55
cache_info: 8 hits, 11 misses, 11 entries
fib(10) again = 55
hits afterwards = 9
fib(100) = 354224848179261915075
cache_info() reports hits, misses and the number of entries. The second call to fib(10) moves hits from 8 to 9, which is how you know it did not recurse again.
One line of @lru_cache turns exponential into linear. Two things to watch:
- cached arguments must be hashable. Hand it a list and it fails outright:
TypeError: unhashable type: 'list'
Pass a tuple instead — total((1, 2, 3)) — and it returns 6. That is exactly what Q5 tests.
maxsizecaps the number of entries;maxsize=Nonemeans no limit. Give it a number and you get LRU eviction: bounded memory, at the cost of recomputing what was evicted.
Reference: extended mechanisms, legacy examples and submission details
This section is for after class; it is not part of the ninety minutes.
Worst, best and average case
The same algorithm at the same size can take different amounts of time on different inputs. Searching a list for a value, for instance: the target sitting first and the target being absent differ enormously in how far you scan.
- worst case: the cost of the most expensive input. Unless stated otherwise, this course analyses the worst case
- best case: the cheapest input; usually not worth quoting
- average case: the expectation over all inputs. It requires an assumption about how inputs are distributed, and without that assumption the conclusion does not hold
What the asymptotic notation actually says
O(f(n)) is an upper bound, not an exact step count. n + 2 is written O(n), but n + 2 is also O(n²) — n² really does bound it from above. What we usually write is the tightest such bound.
Two places students go wrong:
- dropping constants is only safe when the input is large enough and the orders differ
- when the orders agree, the constant is the only thing left that matters (square-root pruning versus 6k±1 is exactly this case)
Polynomial evaluation: one formula, different multiplication counts
The naive form raises to a power term by term:
def naive(x, coefficients):
total = 0
for power, c in enumerate(coefficients):
total += c * x ** power
return total
Horner’s form replaces the powers with repeated multiply-and-add:
def horner(x, coefficients):
total = 0
for c in reversed(coefficients):
total = total * x + c
return total
The two are mathematically identical, but with 400 coefficients they measured about 37× apart. The difference is not the order (both O(n)) but the number of multiplications per iteration.
The list memory model
Two assumptions:
- elements are stored contiguously
- the index and the element correspond one to one
Together they give: a[i] is O(1) (one address calculation), while a.insert(0, x) and a.pop(0) are O(n) (everything after shifts by one). Measured inserting at the front on the build machine:
| n | time |
|---|---|
| 5,000 | 0.0015 s |
| 10,000 | 0.0046 s |
| 20,000 | 0.0199 s |
Double n and the time roughly quadruples — the O(n) operation is itself being performed n times.
Hash tables
Dict and set lookups are O(1) because they compute the storage location with a position function instead of comparing from the front. The price is space for time: the table reserves more slots than it holds elements.
Collisions are real and have standard remedies. This course does not ask you to design a hash function, only to know the contract.
The hash contract
hash(1) == hash(1.0) -> True
hash(True) == hash(1) -> True
hash((1, 2)) -> fine
hash([1]) -> TypeError
hash({"a": 1}) -> TypeError
The rules:
- equal objects must have equal hashes.
1 == 1.0 == True, so all three hash the same - mutable containers are unhashable. Their contents can change; if they could be dict keys, changing the contents would leave the key unfindable
- a class that defines
__eq__and not__hash__has__hash__set toNone, so its instances cannot be hashed
To use your own objects as dict keys, supply a consistent __eq__ and __hash__ together. The easier route is usually to make the object immutable, or to key on a tuple.
The cost of recursion
Naive recursive Fibonacci, measured:
| n | calls |
|---|---|
| 20 | 21,891 |
| 25 | 242,785 |
| 28 | 1,028,457 |
The call count runs about three times the answer, meaning the overwhelming majority of calls recompute something already known.
There is also a depth limit. sys.getrecursionlimit() measured 1000 on the build machine; past that comes RecursionError. So a naive fib(2000) does not merely run slowly, it crashes — while the memoized version descends only one level at a time and never hits the limit.
Two ways to write the cache
A dictionary (better when the subproblems are sparse):
cache: dict[int, int] = {}
def fib(n: int) -> int:
if n in cache:
return cache[n]
if n <= 1:
return n
result = fib(n - 1) + fib(n - 2)
cache[n] = result
return result
A list (faster when they are dense, but you need a known bound):
def fib(n: int, memo: list[int]) -> int:
if memo[n] != -1:
return memo[n]
if n <= 1:
return n
memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
return memo[n]
memo = [-1] * (n + 1)
Both are O(n) time and O(n) space; they differ in lookup constants and in how much memory is actually used.
Space and recursion
Each level of a recursive call saves its own state, so naive fib also occupies O(n) of stack. The iterative version finishes in O(1) space:
def fib_iter(n: int) -> int:
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
L13 takes “produce one value at a time instead of building a list” to its limit with iterators and generators.
Forms of raise
raise ValueError("message") # class plus argument; most common
raise ValueError # type only, no message
raise # bare raise, re-raise the current exception
A bare raise works only inside an except block, and its purpose is to continue upward with the original traceback:
try:
...
except ValueError:
print("logging this")
raise # hand it to the caller without losing the traceback
Exception chaining keeps the cause explicit:
except KeyError as error:
raise ValueError("the configuration is missing a field") from error
Multiple handlers and the fallback
except (KeyError, IndexError): # tuple form, two types together
The except clauses of one try do not catch each other’s exceptions. An exception raised inside an except block will not be caught by a sibling clause; it travels upward.
When you need a catch-all:
except Exception as error:
print("unexpected problem:", error)
raise
That records the information without swallowing the failure.
Custom exception hierarchies
When a module defines several related exceptions, the usual pattern is a base class plus subclasses:
class ConfigError(Exception):
"""Base class for configuration problems."""
class ConfigMissing(ConfigError):
"""A required field is absent."""
class ConfigFormat(ConfigError):
"""A field has the wrong format."""
A caller can catch ConfigError to take the whole family, or name a subclass for precision. Once L09’s Temperature raises instead of returning False, its interface changes from a value the caller may ignore to a TemperatureError the caller must deal with.
Resource and system errors
| Exception | Usual source |
|---|---|
OSError (including FileNotFoundError, PermissionError) | files, directories, permissions |
ValueError | a failed conversion, such as int("abc") |
TypeError | mismatched types |
KeyError / IndexError | missing key / index out of range |
with and a hand-written try/finally achieve the same thing, but with is far shorter. One boundary: __exit__ is called only if __enter__ returned successfully, and if __exit__ returns a truthy value the exception propagating out of the block is suppressed. A file object returns falsy on a normal close, so exceptions travel outward as usual.
Inside an except block, sys.exc_info() gives the (type, value, traceback) triple of the current exception; it is mostly for logging.
What float() actually accepts
The old notes treated float("inf") as invalid input. Measurement disagrees:
| Input | Result |
|---|---|
"123", "-3.14", "+123" | ordinary floats |
" 42 " | 42.0; surrounding whitespace is ignored |
"1_0" | 10.0; the underscore separator is honoured |
"1e5" | 100000.0; scientific notation |
"inf", "Infinity", "nan" | infinity and NaN |
"123" | 123.0; full-width digits too |
"", "1 2", "0x10" | ValueError |
So “is this string a number” cannot be answered by guessing at formats. Either call float() and catch ValueError, or state precisely which format you accept. Note also that after float("nan"), nan != nan — a comparison trap.
pytest conventions and options
| Option / form | Effect |
|---|---|
-v | show every test’s name and result |
-q | progress dots and a summary only |
-k expression | run only tests whose names match |
pytest.raises(Error) | expect the given exception |
pytest.raises(Error, match="...") | also check the message |
pytest.approx(0.1 + 0.2) == 0.3 | float comparison without precision surprises |
Test files live beside the code or in a tests/ directory and are named test_*. pytest discovers them on its own; nothing has to be registered by hand.
Float comparison deserves its own line: 0.1 + 0.2 == 0.3 measured False, because binary fractions cannot hold those three values exactly. Compare floats with pytest.approx, or allow a tolerance in the assertion.
Why the notes and a real test suite differ
The code blocks in these notes are single-file and reproducible — they run as they are. pytest cannot work that way: it needs to discover files and collect test functions. So this section shows the same idea as a checking loop, and quotes the real pytest output in section III.
Both check the same thing: when the expected value and the actual value disagree, print enough to locate the problem.
The edge-case checklist
| Category | Examples |
|---|---|
| normal | typical input |
| empty | empty list, empty string |
| single | exactly one element |
| boundary | 0, negatives, largest and smallest, equal bounds |
| repetition | duplicate elements, consecutive spaces |
| failure | does invalid input raise as agreed |
At least one case per branch. When an AI helps you add cases, still list this table yourself first — L8 found that “empty” and “one” are the two an AI most often misses.
How textstats behaves under test
| Call | Result |
|---|---|
word_count("") | 0 |
word_count("a b") | 2 (consecutive spaces do not make an empty word) |
longest_word("") | ValueError: max() arg is an empty sequence |
longest_word("bb aa") | "bb" (on a tie, the first one) |
average_word_length("") | ZeroDivisionError: division by zero |
The docstrings say “return the longest word” and “return the mean word length”; neither pins down the empty string. That is what a test is for: put the unpinned-down input on the table and make the discrepancy a visible fact, rather than reading the implementation first and writing a test that is guaranteed to pass.
The five prime methods in full
Brute trial division
def count_primes_brute(n: int) -> int:
count = 0
for p in range(2, n):
for d in range(2, p):
if p % d == 0:
break
else:
count += 1
return count
The for...else earns its keep here: else runs when the loop was never broken out of, that is, when no divisor was found. O(n²).
Square-root pruning
def count_primes_sqrt(n: int) -> int:
count = 0
for p in range(2, n):
d = 2
while d * d <= p:
if p % d == 0:
break
d += 1
else:
count += 1
return count
If p has a factor at all, one of them is at most √p, so trial division up to √p suffices. O(n√n).
6k±1 pruning
Rule out multiples of 2 and 3 first; what remains are numbers of the form 6k±1, so the candidate divisors reduce to 5, 7, 11, 13, …:
def count_primes_6k(n: int) -> int:
if n <= 2:
return 0
if n <= 3:
return 1
count = 2 # 2 and 3
for p in range(5, n):
if p % 2 == 0 or p % 3 == 0:
continue
d = 5
while d * d <= p:
if p % d == 0 or p % (d + 2) == 0:
break
d += 6
else:
count += 1
return count
The order is unchanged at O(n√n), but roughly two thirds of the trial divisions disappear — measured, about three times faster.
The sieve of Eratosthenes
def count_primes_sieve(n: int) -> int:
if n <= 2:
return 0
flags = [True] * n
flags[0] = flags[1] = False
p = 2
while p * p < n:
if flags[p]:
for multiple in range(p * p, n, p):
flags[multiple] = False
p += 1
return sum(flags)
Marking starts at p * p, because smaller multiples were already marked by smaller primes. O(n log log n) time, O(n) space.
Miller–Rabin
Handle the small cases, write n−1 as d × 2ʳ, and test a^d mod n for each base a:
def is_prime_mr(n: int) -> bool:
if n < 2:
return False
for base in (2, 3):
if n == base:
return True
if n % base == 0:
return False
d, r = n - 1, 0
while d % 2 == 0:
d //= 2
r += 1
for base in (2, 3):
x = pow(base, d, n)
if x == 1 or x == n - 1:
continue
for _ in range(r - 1):
x = x * x % n
if x == n - 1:
break
else:
return False
return True
The domain has to be stated. With bases 2 and 3 the conclusion is certain for n < 1,373,653. This week’s range, n ≤ 100000, is inside it. It does not generalise to arbitrary large integers — past that bound these two bases are only probabilistic evidence.
pow(base, d, n) is the three-argument built-in, doing modular exponentiation. Each test costs O(log³ n) (the number of digits is log n, and modular exponentiation is cubic in the digits), in O(1) space.
Timing fairly
The numbers in a report have to be comparable, so:
- verify correctness before timing. All five methods must return the same count at the same small size
- fix the size, use one machine, state the environment
- repeat after a warm-up and take the minimum, to avoid scheduler noise
- a slow method may use a size it can finish (brute force, say, only up to 2000), but it may not swap in a different algorithm and pass it off
The figures in this section come from one measurement on the build machine. They are for comparing orders and ratios, not a promise about seconds on your laptop.
The solo assignment 2 specification
| Item | Agreement |
|---|---|
| five methods | count_primes_brute, count_primes_sqrt, count_primes_6k, count_primes_sieve, count_primes_mr |
| range | 0 ≤ p < n |
| size | 0 ≤ n ≤ 100000 |
| small values | n ≤ 2 returns 0 |
| invalid input | negative n raises ValueError |
| timing | verify correctness first, then record the size each method finishes |
| report | marked by a human: method notes, timing table, call counts, usage declaration |
As configured, solo assignment 2 covers weeks 10 and 11, its due date reads the shared calendar, and each of the three solo assignments is worth 5%. Complete reference solutions are not published; the markers check that each method is a real implementation.
The seven Lab 11 entry points
| Entry | Contract |
|---|---|
w11-complexity-measure | a timing helper plus four measurement functions; return comparable dictionaries, do not print |
w11-string-build | build_concat and build_join return the same string; build_concat_keep is the comparison case |
w11-memoize | count_ways and count_ways_memo agree; count_calls reports the call count |
w11-safe-convert | safe_int, safe_float, safe_div return defaults on failure and catch only specific types |
w11-config-review | a runnable load_config, rewritten with explicit exceptions and with |
w11-custom-exception | Temperature.set_celsius raises TemperatureError on invalid values, returns None otherwise |
w11-tests | only the contents of a test file for textstats.py; at least four cases, at least one pytest.raises |
The test problem is marked not only on whether the tests pass but on whether they cover empty, single, boundary and invalid input. This week’s lab declares AI use at Level 1.
Summary
Complexity
- Measure algorithms by counted basic operations, independent of the machine
- Big-O keeps the highest-order term and drops constants, because a different order always wins eventually
- The ladder: O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ)
- Learn Python’s costs:
inon a list is O(n); on a set or dict it is O(1) - The cost of
+=on strings in a loop depends on whether other references exist; writejoin, which does not rely on that detail - Memoization takes overlapping-subproblem recursion from O(2ⁿ) to O(n); the key must contain every varying argument
- Built-ins are faster by a smaller constant and cannot rescue a bad algorithm. Fix the order first
Exceptions
- Valid code still fails at run time; that is an exception
try/except SpecificType/else(nothing raised) /finally(always)- Never write a bare
except: raisethrows your own; custom exceptions inherit fromExceptionwithguarantees cleanup, equivalent to a well-writtentry...finally- Python prefers EAFP over LBYL
Assertions and tests
- Exceptions handle expected external problems; assertions check impossible internal states
- Never catch
AssertionError— a failed assertion should crash - Assertions are skipped under
-O, so they cannot validate user input assert (condition, message)as a tuple is always true; do not put side effects in an assertion- pytest:
test_-prefixed files and functions,assertfor expectations,pytest.raisesfor failures - The platform calls your function and compares the return value — so you must
return - Test at least: normal, empty, single, boundary, failure
Exercises
- Give the time complexity of each and justify it:
def f1(n): return sum(range(n)) def f2(a): return [x for x in a if x in a] def f3(a): return sorted(set(a)) def f4(n): count = 0 i = n while i > 1: i = i // 2 count += 1 return count - Add memoization to your
count_ways(coin combinations) from L7 and measure the speedup. Work out which arguments belong in the key. - Write
safe_int(text: str, default: int = 0) -> int, returning the default instead of crashing on failure. Catch only specific exception types. - What is wrong with this? Fix it:
def process(filename): try: f = open(filename) data = f.read() return int(data) except: return 0 - Write a pytest suite for the
Stackclass you built in Lab 9, covering at least: empty, one element, several elements, and popping an empty stack. - Add pre- and postconditions with
assertto L7’sbinary_search. (Hint: the postcondition can check that the returned index really holds the target, or that −1 was returned and the target really is absent.) - Measure it yourself: time 10,000 calls each of
list.insert(0, x)andlist.append(x)withtime.perf_counter()and confirm the O(n) versus O(1) difference.
The lab problems are in Lab 11; the solo assignment is Solo assignment 2.
Sources
- Python 3.13 compound statements: the semantics of try / except / else / finally / with
- Python 3.13 built-in exceptions: the hierarchy and the common types
- functools.lru_cache
- Miller–Rabin primality test: deterministic bases over small ranges