Complexity Analysis and the Cost of Operations
Every coding interview ends with the question "what is the time and space complexity?" A wrong answer here undoes good work, and a confident, justified answer is a strong signal. Complexity analysis is a small set of skills: counting how work grows with the input, recognising recursion patterns, understanding amortised cost, and knowing what the operations of your language's data structures actually cost. This chapter teaches each, with code you can run.
1. What big-O means, in practice
Big-O describes how the number of steps (or amount of memory) grows as the input size grows, ignoring constant factors and lower-order terms. We say an algorithm is when its work grows no faster than a constant times for large .
Two rules do most of the work:
- Drop constants. and are both . The constant matters in practice, and big-O deliberately ignores it, to describe growth.
- Keep the dominant term. is , because for large the square dominates.
Related notation, which you may be asked about:
- (omega) is a lower bound: the work is at least this.
- (theta) is a tight bound: the work is both at most and at least this, up to constants.
- In interviews, "big-O" is usually used loosely to mean the tight worst case. If it matters, say "worst case".
Average, worst and best case. A hash lookup is on average and in the worst case if everything collides. Quicksort is on average and in the worst case. When the cases differ, state which you mean.
<!--fig:growth-->2. Counting loops
The simplest analysis is counting how many times the innermost statement runs.
One loop. Work proportional to the number of iterations.
def total(nums):
s = 0
for x in nums: # n iterations
s += x # constant work
return s # O(n) time, O(1) space
assert total([1, 2, 3]) == 6
Nested loops. Multiply, when the inner loop count does not depend on the outer one.
def count_pairs(n):
steps = 0
for i in range(n):
for j in range(n):
steps += 1
return steps # n * n
assert count_pairs(10) == 100 # O(n^2)
Dependent loops. When the inner bound depends on the outer variable, sum the series.
def count_triangle(n):
steps = 0
for i in range(n):
for j in range(i): # 0 + 1 + 2 + ... + (n-1)
steps += 1
return steps
assert count_triangle(10) == 45 # n(n-1)/2, which is O(n^2)
The sum is still . A loop pair like this often looks cheaper than and is not, in big-O terms.
Sequential loops. Add them, and keep the larger.
def two_passes(nums):
best = max(nums) # O(n)
return [x - best for x in nums] # O(n); total O(n) + O(n) = O(n)
assert two_passes([1, 5, 3]) == [-4, 0, -2]
Loops that shrink the problem geometrically are logarithmic.
def halvings(n):
steps = 0
while n > 1:
n //= 2 # n, n/2, n/4, ... until 1
steps += 1
return steps
assert halvings(1024) == 10 # log2(1024); O(log n)
Multiplying or dividing the loop variable by a constant each iteration gives . Adding a constant gives .
A loop over with a logarithmic body is , which is what you get from "for each element, do a binary search or heap operation".
3. Analysing recursion
For recursive functions, write the recurrence: how the work for size relates to the work for smaller sizes, plus the work done at this level.
One recursive call, constant extra work. , which is calls.
def fact(n):
return 1 if n <= 1 else n * fact(n - 1) # n calls, O(n) time, O(n) stack
assert fact(5) == 120
One call on half the input, constant extra work. , which is . This is binary search.
Two calls on half the input, linear extra work. , which is . This is merge sort: the tree of calls has levels, and each level does total work.
def merge_sort(a):
if len(a) <= 1:
return a
mid = len(a) // 2
left, right = merge_sort(a[:mid]), merge_sort(a[mid:]) # two half-size calls
out, i, j = [], 0, 0
while i < len(left) and j < len(right): # linear merge
if left[i] <= right[j]:
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
return out + left[i:] + right[j:]
assert merge_sort([5, 2, 9, 1, 5, 6]) == [1, 2, 5, 5, 6, 9]
Two calls on nearly the same size. , which is . The naive Fibonacci is the classic example, and it is why memoisation matters.
calls = 0
def fib_naive(n):
global calls
calls += 1
return n if n < 2 else fib_naive(n - 1) + fib_naive(n - 2)
assert fib_naive(15) == 610
assert calls == 1973 # grows roughly like 1.6^n
A shortcut for divide and conquer. For , compare the work at the top level with the work at the bottom (the master theorem, in plain words):
- If the top level dominates ( large compared with ): .
- If every level does equal work: .
- If the leaves dominate: .
You need this to explain results, not to derive them. Remember three cases: binary search , merge sort , and a recursion that makes two calls and does little work is exponential in the depth.
A picture method. Draw the recursion tree. Count the nodes, and the work per node. The total is the sum over all nodes. For merge sort, levels each with total work .
4. Space complexity
Space is the extra memory the algorithm uses, beyond the input, as a function of .
- Variables and a fixed number of pointers: .
- A new list or dictionary that can grow to items: .
- A 2D table of : .
- Recursion uses the call stack. A recursion that goes deep uses stack space, even if it allocates nothing else. Binary search written recursively uses stack, and the iterative version uses .
State whether you count the output in space. Many interviewers do not count the returned result, and it is polite to say which you mean.
Trade-offs. Many optimisations spend memory to save time: a hash map in two sum, a memo table in dynamic programming, a precomputed prefix sum. Say so explicitly: "this trades space for time".
In Python, a hidden source of space is slicing. a[:mid] creates a new list of that size, so the merge sort above uses total allocation, and a recursive binary search that slices the list is , not . Pass indexes instead of slices when it matters.
5. Amortised analysis
Some operations are usually cheap and occasionally expensive. Amortised cost is the average cost per operation over a sequence, which can be far below the worst single operation.
Dynamic array append. Appending to a Python list is usually constant, but when the capacity is full it allocates a bigger array and copies everything, which costs . If the capacity doubles each time, the copies cost in total, spread over appends, so each append is amortised.
The two-pointer and sliding window argument. A while loop nested inside a for loop looks like , but if the inner pointer only ever moves forward and never resets, the total movement of the inner pointer over the whole run is at most , so the total work is .
def longest_unique_substring(s):
last = {}
best = start = 0
for i, ch in enumerate(s):
if ch in last and last[ch] >= start: # shrink window from the left
start = last[ch] + 1 # start only moves forward: at most n moves in total
last[ch] = i
best = max(best, i - start + 1)
return best # O(n) time overall, O(k) space for k distinct chars
assert longest_unique_substring("abcabcbb") == 3
assert longest_unique_substring("bbbbb") == 1
assert longest_unique_substring("pwwkew") == 3
The claim "each element is added once and removed once" is the standard way to justify linear time for such loops.
6. The cost of common operations
Interviews are often lost by a hidden cost. Know these for Python, and the equivalents in your language.
| Structure | Operation | Cost |
|---|---|---|
list | index a[i], append, pop() from the end | (append amortised) |
list | pop(0), insert(0, x), x in a, a.index(x), remove | |
list | slicing a[i:j], a[:], copy | |
list | sort() | |
dict, set | get, set, delete, in | average, worst |
collections.deque | append and pop at either end | |
collections.deque | index in the middle | |
heapq | heappush, heappop | |
heapq | heapify a list | |
str | concatenation s + t | |
str | in, find substring | worst, usually faster |
sorted(a) | returns a new sorted list | time, space |
min(a), max(a), sum(a), len(a) | scan, except len | , len is |
Consequences you should act on:
- Do not use a list as a queue.
pop(0)is linear, so a BFS on a list is quadratic. Usedeque. - Do not test membership in a list inside a loop. Convert to a
setfirst. - Do not build a string with
+=in a loop for large outputs. Collect pieces in a list and"".join(...). - Slicing copies.
a[1:]in a recursive call makes the recursion . - Sorting dominates many solutions. If a problem allows sorting first, the answer is at least .
from collections import deque
import heapq
# O(1) at both ends
q = deque([1, 2, 3])
q.appendleft(0); q.append(4)
assert q.popleft() == 0 and q.pop() == 4
# O(log n) push and pop; O(1) peek at the smallest
h = []
for x in [5, 1, 4]:
heapq.heappush(h, x)
assert heapq.heappop(h) == 1 and h[0] == 4
7. Hash map caveats
Hash tables give average lookups, because a good hash function spreads keys evenly. The worst case is when many keys collide. In interviews, state " average" and move on, unless asked. Be ready to explain collisions (chaining or open addressing), resizing (the table doubles when it gets full, which is amortised per insert), and why keys must be hashable (immutable): a list cannot be a dictionary key, but a tuple of numbers can.
8. Putting it together
Analyse a full function.
def has_duplicate_within_k(nums, k):
window = set()
for i, x in enumerate(nums):
if x in window: # O(1) average
return True
window.add(x) # O(1) average
if len(window) > k:
window.remove(nums[i - k]) # O(1) average; each element removed at most once
return False
assert has_duplicate_within_k([1, 2, 3, 1], 3) is True
assert has_duplicate_within_k([1, 2, 3, 1, 2, 3], 2) is False
Time: one loop of iterations, constant average work per iteration, so . Space: the window holds at most elements, so . Notice how the answer comes from naming the cost of each operation, not from guessing.
9. Common mistakes
- Treating a nested loop as quadratic without checking whether the inner pointer resets, as in sliding window.
- Forgetting recursion stack space. A recursive DFS on a deep graph uses memory, and in Python it can hit the recursion limit.
- Hidden operations inside a loop:
x in list,list.pop(0), slicing, string concatenation,list.index. - Quoting average-case as worst-case, or the reverse, without saying which.
- Counting input size wrongly. For a graph, state complexity in terms of both and . For a 2D grid, in terms of the number of cells ().
- Saying "" for anything that looks fast. Logarithmic needs the problem to shrink geometrically.
- Ignoring the cost of the output. Generating all subsets is simply because the output has that size.
10. Quick reference
| Pattern in the code | Typical complexity |
|---|---|
| Single pass | |
| Nested pass over the same data | |
| Halving the range each step | |
| Sort, then a pass | |
| For each element, a heap or binary-search operation | |
| Recursion with two branches and no memo | |
| Recursion with memoisation over states | |
| All subsets | |
| All permutations | |
| Grid traversal | |
| Graph traversal |
11. Practice
- State the time and space complexity of each function earlier in this chapter without looking, then check.
- Write the recurrence for a recursive binary search and prove it is .
- Rewrite
merge_sortto pass indexes instead of slices. What is the new space complexity? - Explain why building a list with
insert(0, x)times is and what you would use instead. - For the code
for i in range(n): for j in range(i, n): ..., count the iterations exactly and state the big-O.