Understanding Big O is not about always choosing the lowest complexity. It is about making informed decisions. Sometimes O(n²) is fine. Sometimes O(n log n) is not fast enough. This lesson brings the theory into engineering reality.
The Optimisation Trap
The most common mistake after learning Big O is over-optimising. Consider:
def find_user(users, user_id):
for user in users: # O(n)
if user.id == user_id:
return user
return NoneIs this a problem? It depends on n.
- If
usersis always < 100 entries: No. The linear scan runs in microseconds. - If
usersgrows to millions and this runs on every HTTP request: Yes. Index the database.
Rule of thumb: optimise when measured performance is a problem, not when theoretical complexity looks bad on paper.
When O(n²) Is Acceptable
Quadratic algorithms are perfectly acceptable when n is small and bounded:
def match_tags(post_tags, filter_tags):
for tag in post_tags: # n tags per post
if tag in filter_tags: # m filter tags
return True
return FalseIf posts have at most 10 tags and filters have at most 5, the worst case is 50 comparisons. No hash set required. The simpler code wins.
A general guide: if n < 1,000 and the function is not called in a hot loop, O(n²) is usually fine. If n > 10,000 or the function runs on every request, consider O(n log n) or better.
The Three-Way Tradeoff
Real engineering decisions involve three dimensions: time, space, and code complexity (readability/maintainability).
| Approach | Time | Space | Code Simplicity |
|---|---|---|---|
| Naive nested loop | O(n²) | O(1) | High |
| Sort then scan | O(n log n) | O(1) | Medium |
| Hash set | O(n) | O(n) | Medium |
| Precomputed lookup | O(1) | O(n) + setup | Low |
None of these is universally best. The right pick depends on:
- How large is n?
- How often is this called?
- How constrained is memory?
- How often does the underlying data change?
Amortised Complexity
Some data structures are occasionally slow but fast on average. Python's list append is the canonical example.
Most of the time, appending to a list is O(1) — there is spare capacity and the element is written directly. Occasionally, the list is full and must be resized: a new array of double the size is allocated and all elements are copied — O(n).
But because the capacity doubles each time, a resize happens only after 1, 2, 4, 8, 16, ... elements are added. The total cost of n appends is O(n). Divided across n operations, the amortised cost per append is O(1).
This is why "append is O(1) amortised" is the correct statement — not that any individual append is O(1).
Prefix Sums: Paying Space to Gain Time
A classic time-space tradeoff. Suppose you need to answer many range sum queries on an array.
Naive approach: for each query (i, j), sum arr[i..j] — O(n) per query.
Prefix sum approach: precompute once, answer in O(1).
def build_prefix(arr):
prefix = [0] * (len(arr) + 1)
for i, x in enumerate(arr):
prefix[i + 1] = prefix[i] + x # O(n) setup
return prefix
def range_sum(prefix, i, j):
return prefix[j + 1] - prefix[i] # O(1) queryIf you have n elements and q queries:
- Naive: O(n × q) total
- Prefix sums: O(n) setup + O(q) queries = O(n + q) total
For 1,000 elements and 10,000 queries: naive = 10,000,000 operations; prefix sums = 11,000. The O(n) space for the prefix array is worth it.
Choosing Between Sorting and Hashing
Two common O(n log n) vs O(n) alternatives:
| Situation | Sort | Hash |
|---|---|---|
| Need sorted output | Always sort | Cannot hash |
| Need fast lookup only | Sort + binary search (O(log n) per lookup) | Hash (O(1) per lookup) |
| Memory is very tight | Sort in-place (O(1) extra) | Hash needs O(n) extra |
| Keys are non-hashable | Sort works | Hash requires hashable keys |
If you just need to check membership repeatedly, a hash set beats sorting. If you need the data in sorted order or need to find nearest values, sorting wins.
Complexity of Common Data Structure Operations
| Operation | Array | Linked List | Hash Map | BST (balanced) | Heap |
|---|---|---|---|---|---|
| Access by index | O(1) | O(n) | O(1) | O(log n) | O(n) |
| Search | O(n) | O(n) | O(1) avg | O(log n) | O(n) |
| Insert (end) | O(1) amort | O(1) | O(1) avg | O(log n) | O(log n) |
| Insert (middle) | O(n) | O(1)* | O(1) avg | O(log n) | — |
| Delete | O(n) | O(1)* | O(1) avg | O(log n) | O(log n) |
| Find min/max | O(n) | O(n) | O(n) | O(log n) | O(1) |
*Given a pointer to the node. Finding the node is O(n).
Knowing this table lets you choose data structures by the operations your problem requires, not by familiarity.
The Practical Decision Framework
When you encounter a performance problem:
- Measure first. Profile before assuming which part is slow.
- Identify n. What is the actual input size in production? Is it growing?
- Find the hot path. A O(n²) function called once is not the same as one called 10,000 times per second.
- Consider the full tradeoff. Faster code that is unreadable may be a net loss.
- Use the right data structure. Most performance problems are data structure problems.
Key Takeaways
- Big O is a tool for making decisions, not for always choosing the lowest complexity.
- Optimise when performance is measured to be a problem, not when code looks slow in theory.
- Amortised complexity spreads occasional expensive operations across many cheap ones — list append is O(1) amortised.
- Prefix sums, memoisation, and hash sets are the most common space-for-time tradeoffs.
- Most performance problems are data structure problems — learn the complexity of each operation for arrays, hash maps, trees, and heaps.