Measure, Then Change Complexity
Measure, Then Change Complexity
The dominant win is almost always a better algorithm or data structure — in on a list is O(n) while dict/set lookup is O(1):
ids = {r.id for r in rows} # build once
selected = [r for r in rows if r.id in ids] # O(1) per probe
Benchmark the change honestly:
import timeit
timeit.timeit(lambda: lookup(values), number=100_000)
Before touching code, run cProfile and confirm the hotspot is where you think. Then classify the candidate: loop removal (algorithmic), caching repeated work (memoization), or micro-tweaks (local binding, comprehension) — only the first two usually move the needle, and all three must be re-measured afterward to prove the win survived.
Caching What Is Pure
Caching What Is Pure
from functools import lru_cache, cache, cached_property
@lru_cache(maxsize=256) # bounded LRU eviction, 3.8+
def parse(expr: str) -> AST: ...
@cache # unbounded — 3.9+, use only for small domains
def fib(n: int) -> int: ...
class Config:
@cached_property # computed once per instance, stored in __dict__
def parsed(self): return json.loads(self.raw)
Hazards to state before an interviewer nods:
- Arguments must be hashable (no list/dict parameters)
typed=False(default) makesf(1)andf(1.0)collide becausehash(1) == hash(1.0)— passtyped=Truewhen that matters- Mutable arguments invalidate the premise — the cache keys on the value at call time, not later mutation
- Unbounded
cacheon a wide input domain is a memory leak wearing a speedup costume
Structure-Level Micro-Optimizations
Structure-Level Micro-Optimizations
Once algorithms and caching are right, these measurements-backed tweaks help hot paths:
| Change | Why it wins |
|---|---|
__slots__ on mass-instantiated classes |
skips per-instance __dict__ allocation and lookup |
| Bind hot globals/attributes to locals in a loop | LOAD_FAST beats LOAD_GLOBAL/LOAD_ATTR per iteration |
"".join(parts) over s += x |
strings are immutable — each += copies |
Comprehensions over append loops |
fewer method lookups, tighter bytecode |
collections.deque for head/tail pops |
list pop(0) shifts the whole array |
defaultdict/set membership |
removes repeated lookups and setdefault noise |
Guardrails: __slots__ forbids new attributes; premature local-binding hurts readability. Interview answer: state the profile evidence, the chosen lever, and the before/after number.
Quiz
1. Why is "x in values" slow for a large list but fast for a set?
2. What does @lru_cache(typed=False) do with f(1) and f(1.0)?
3. Which is NOT a benefit of __slots__?
Flashcards
Question
lru_cache vs cache
Click to reveal answer
Answer
lru_cache(maxsize) evicts by LRU; cache() is unbounded (3.9+) — safe only for small domains.
Question
Hashable arguments rule
Click to reveal answer
Answer
Cache keys need hashability; lists/dicts as params raise TypeError.
Question
Optimization ladder
Click to reveal answer
Answer
Profile → algorithm/data structure → caching → micro-tweaks → re-measure.
Question
deque vs list.pop(0)
Click to reveal answer
Answer
deque pops at both ends O(1); list.pop(0) shifts every element O(n).
Revision Notes
Key Takeaways
- 1. Profile first; asymptotic fixes dwarf micro-optimizations
- 2. Caching requires pure functions, hashable args, and bounded domains
- 3. Measure-verify every tweak and keep readability as a real cost in the ledger
Interview Tips
- • Lead with evidence: name the profiler output that justified the change
- • List lru_cache caveats (hashability, mutability, typed=False, unbounded growth) without prompting
Cheat Sheet
dict/set O(1) vs list O(n); @lru_cache(maxsize)/@cache; typed=False hash(1)==hash(1.0); slots cut dict; join not +=; measure with timeit.