Time Complexity of Collection Operations
Overview
Every Python collection operation has a time complexity — a measure of how the number of steps grows as the collection grows. Big-O notation describes that growth rate: - `O(1)`: Constant time, the same speed regardless of size. - `O(n)`: Linear time, you must scan every element in the worst case. - `O(n log n)`: Log-linear time, typical of efficient sorting algorithms. Think of a `dict` or `set` as a filing cabinet with labelled drawers: you walk straight to the right drawer in `O(1)`. A `list` used for membership testing is an unlabelled pile: you must rifle through every item, `O(n)`. Here are key collection operations and their average-case complexities: - `xs[i]`: Accesses an element by index in a `list` or `tuple` in `O(1)`. Returns the element, or raises `IndexError` if the index is out of bounds. - `list.append(x)`: Adds an item to the end of a `list` in amortised `O(1)`. Returns `None`. - `list.insert(0, x)`: Inserts an item at the beginning of a `list` in `O(n)`. Returns `None`. - `d[key]`: Accesses a value by key in a `dict` in `O(1)`. Returns the value, or raises `KeyError` if the key is missing. - `s.add(x)`: Adds an item to a `set` in `O(1)`. Returns `None`. - `x in c`: Checks if an item exists in a collection. Returns a `bool`. This is `O(1)` for `dict` and `set`, but `O(n)` for `list` and `tuple`. The practical takeaway: whenever you need to check whether a value exists in a collection, convert it to a `set` or use a `dict`. A membership test against a `list` of 1,000,000 items is up to 1,000,000× slower than the same test against a `set`. Example — filtering allowed user IDs from an API request: allowed = {'u-101', 'u-202', 'u-303'} # set, O(1) lookup payload_ids = ['u-202', 'u-999', 'u-101'] valid = [uid for uid in payload_ids if uid in allowed] Example — building a fast cache index: cache = {} # dict, O(1) lookup for record in db_rows: cache[record['id']] = record Removing elements from the front of a `list` is a common performance trap: queue = [1, 2, 3, 4] first_item = queue.pop(0) # O(n) operation! Popping from the front of a `list` is `O(n)` because every remaining element must shift left in memory. If you need a fast queue, use `collections.deque` which offers `O(1)` appends and pops from both ends (covered fully in collections-deque).
Choosing the wrong collection for the job is the most common performance mistake in Python. A single `if item in big_list` inside a loop can silently turn an `O(n)` endpoint into an `O(n²)` endpoint. Understanding these costs lets you pick the right structure before profiling tells you something is slow.
Where used: API response filtering, database query result processing, in-memory caches, Django querysets, FastAPI dependency checks
Why learn this
- Explains why swapping a `list` for a `set` can turn a slow API endpoint from seconds to milliseconds.
- Gives you a mental checklist when choosing between `list`, `dict`, `set`, and `tuple` in production code.
- Commonly tested in interviews: 'Why is `dict` lookup `O(1)`?' and 'What is the cost of `list.insert(0, x)`?'
Code walkthrough
import time
ids = list(range(1_000_000))
id_set = set(ids)
target = 999_999
t0 = time.perf_counter()
target in ids
list_ms = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter()
target in id_set
set_ms = (time.perf_counter() - t0) * 1000
print(f'list: {list_ms:.4f} ms, set: {set_ms:.4f} ms')
Focus: The `set` lookup finishes in microseconds while the `list` scan takes milliseconds — same data, same question, wildly different cost.
Aha moment
data = list(range(100_000))
data_set = set(data)
missing = -1
count = 0
for _ in range(1000):
if missing in data:
count += 1
count2 = 0
for _ in range(1000):
if missing in data_set:
count2 += 1
print(f'list hits: {count}, set hits: {count2}')
Prediction: Both loops run 1000 iterations and neither finds `-1`. But which loop finishes noticeably slower?
Common guess: They take about the same time because neither finds the item.
Not finding an item in a `list` is the worst case: Python must scan all 100,000 elements every iteration, making 100 million comparisons. The `set` checks a hash bucket and returns immediately each time, finishing almost instantly.
Common mistakes
- Using `in` on a list inside a loop: `x in my_list` is `O(n)`. If this sits inside a loop over `m` items, the total cost is `O(m × n)`. Convert the list to a set before the loop: `my_set = set(my_list)`, then test `x in my_set` for `O(1)` each.
- Inserting at the front of a list: `my_list.insert(0, val)` is `O(n)` because every existing element must shift right. If you need fast prepends, use `collections.deque` which offers `O(1)` for `appendleft()`.
- Assuming `dict` or `set` are always `O(1)`: `O(1)` is the average case. In pathological situations with many hash collisions, lookup degrades toward `O(n)`. In practice this is extremely rare with built-in types, but custom `__hash__` implementations that return a constant will trigger it.
- Sorting a list on every request: `list.sort()` is `O(n log n)`. If the data rarely changes, sort once and reuse the result, or maintain a sorted structure, instead of re-sorting on every API call.
Glossary
- time complexity
- A way to describe how the runtime of an operation increases as the amount of data grows, e.g. `O(n)`.
- hash collisions
- When two different keys accidentally get mapped to the exact same location in a hash table, e.g. `hash('key1') == hash('key2')`.
Recall questions
- What is the time complexity of checking `x in my_list` versus `x in my_set`, and why are they different?
- Name two `list` operations that are `O(1)` and one that is `O(n)`.
- Why is choosing a `set` or `dict` for membership checks the single most impactful optimisation for collection code?
- What is the average-case complexity of `dict` get, set, and delete operations?
- Why does `x in my_tuple` have the same complexity as `x in my_list`?
- Why is `queue.pop(0)` a performance trap when used on a `list`, and what should you use instead?
Understanding checks
A developer thinks that `x in my_list` and `x in my_tuple` have different time complexities because tuples are immutable. Why is this incorrect?
Both lists and tuples are linear sequences without hash tables. Therefore, membership testing (`in`) requires a linear scan (`O(n)`) for both.
Immutability affects whether the structure can change, not how items are looked up. To achieve `O(1)` lookups, a hash table (like in a `set` or `dict`) is required.
Why is inserting an element at the beginning of a list `my_list.insert(0, val)` considered an `O(n)` operation?
Because lists are contiguous arrays in memory. Inserting at the front requires Python to shift every subsequent element one position to the right.
Understanding memory layouts helps predict performance costs. If frequent front-insertions are needed, `collections.deque` is a better choice.
What does this code print?
`True`
Both membership tests correctly evaluate to `False` (so `False == False` is `True`). We use parentheses to avoid Python's comparison chaining rules. The `list` scan is `O(n)` while the `set` check is `O(1)`.
What is the time complexity of the `jobs.pop(0)` call and why?
It is `O(n)`. When an element is removed from the front of a `list`, Python must shift every remaining element left by one position to fill the gap.
Lists are backed by contiguous arrays in memory, making them inefficient for queue-like behaviors where elements are removed from the front.
Practice tasks
Modify List to Set Lookup
The provided function checks for allowed roles using a `list`, making it `O(n)` per check. Modify it to convert the `allowed` roles to a `set` first, so that the lookup inside the loop is `O(1)`.
Challenge
Build a fast request-dedup middleware cache
Write a class `RequestDedup` that stores recently seen request fingerprints (strings). It must support: `add(fingerprint)` to record a request, `is_duplicate(fingerprint) -> bool` to check in `O(1)`, and `clear_older_than(cutoff: float)` that removes entries added before a Unix timestamp. Store each fingerprint with its timestamp. Choose data structures so that `add` and `is_duplicate` are `O(1)` average.
Continue learning
Previous: Lists: indexing, slicing, methods
Previous: Tuples: immutability and use cases
Previous: Dictionaries: operations and use cases
Previous: Sets: operations and use cases