~/searching/complexity
Complexity analysis
Estimate how the work grows with the input, and read the limits on n to know which approach will be fast enough.
Count the basic steps as a function of n. Keep the fastest-growing term and drop constant factors: 3n² + 5n is O(n²).
Before you code, to turn the limit on n into a target. After you code, to check your solution fits and to answer "what's the complexity?"
O(n) with a set, O(n²) comparing pairs
O(n) for the set, and the call stack counts
You’ll recognise it when
- The problem gives limits like
1 ≤ n ≤ 2 · 10⁵. That number tells you how fast your solution must be. - You have a correct brute force and want to know if it’s fast enough before you write it.
- The interviewer asks “what’s the time and space complexity?” They almost always do.
- Your code passes small tests but runs out of time on big ones.
Don’t confuse the worst case with the average case: a hash set lookup is O(1) on average but O(n) in the worst case.
The idea
At a party, you want to know whether any two guests share a birthday. One way: every guest talks to every other guest. With 10 guests that’s 45 conversations. With 300 guests it’s 44,850. Another way: put a calendar on the wall. Each guest checks their date as they arrive, then ticks it. That’s one glance per guest: 10 glances, or 300.
Both ways give the right answer. What differs is how the work grows when the party grows. Big-O describes exactly that. Count the basic steps as a function of the input size n, keep the fastest-growing term, and drop constant factors. 3n² + 5n + 100 steps is O(n²), because for large n the n² term swamps the rest.
Here is the “everyone talks to everyone” version in code:
def has_duplicate_slow(nums):
for j in range(len(nums)):
for i in range(j): # every earlier value
if nums[i] == nums[j]:
return True
return False
The inner loop runs 0, 1, 2, … n − 1 times, so there are n(n − 1)/2 comparisons: O(n²). The calendar version keeps a set of the values seen so far and does one lookup per value: O(n).
How it works
We’ll run the set version and, beside it, count what the pair version would do on the same input.
Scroll through the steps and the graphic follows along. You can also press play, step with the arrow keys, or edit the input: try one with no repeats to see the worst case.
- Start with an empty set,
seen. It will hold every value we’ve passed. - Take the next value,
x. - Ask
x in seen. A hash set answers with one lookup on average, however many values it holds. - Not there, so add
xtoseenand move on. Each value costs one lookup and one insert. - Watch the bars. After 10 values the set has done 10 lookups. The pair version has done 0 + 1 + … + 9 = 45 comparisons, because each new value is compared with every earlier one.
- The second 6 is already in
seen, so returnTrue. That’s 12 lookups against 64 comparisons. - Now grow
n. Lookups grow like n, comparisons like n²/2. Doublenand the pair version does four times the work. At n = 200,000 that’s 200,000 lookups against about 2 · 10¹⁰ comparisons.
Why it’s correct: when x is checked, seen holds exactly the values before it. So x in seen asks “has this value appeared earlier?”. The first yes is a duplicate, and if the loop ends, every value was new.
def has_duplicate(nums):
"""True if some value appears more than once in nums."""
seen = set()
for x in nums:
if x in seen:
return True
seen.add(x)
return False#include <unordered_set>
#include <vector>
using namespace std;
// True if some value appears more than once in nums.
bool hasDuplicate(const vector<int>& nums) {
unordered_set<int> seen;
for (int x : nums) {
if (seen.contains(x)) {
return true;
}
seen.insert(x);
}
return false;
}import java.util.HashSet;
import java.util.Set;
class Duplicates {
// True if some value appears more than once in nums.
static boolean hasDuplicate(int[] nums) {
Set<Integer> seen = new HashSet<>();
for (int x : nums) {
if (seen.contains(x)) {
return true;
}
seen.add(x);
}
return false;
}
}Read the constraints first
A computer does very roughly 10⁸ simple steps per second in C++ or Java. Put the largest n into your complexity and compare. If n = 2 · 10⁵ and your idea is O(n²), that’s 4 · 10¹⁰ steps: several minutes, not one second. O(n log n) is about 2 · 10⁵ × 18 ≈ 4 · 10⁶: easy.
The same arithmetic, turned into a lookup table:
| Largest n | Aim for | Typical ideas |
|---|---|---|
| 10 | O(n!) | try every order |
| 20 | O(2ⁿ) | try every subset |
| 500 | O(n³) | three nested loops |
| 5,000 | O(n²) | every pair, a 2D table |
| 10⁵ to 10⁶ | O(n log n) | sorting, a heap, binary search |
| 10⁷ to 10⁸ | O(n) | one pass, a hash set, two pointers |
| ≥ 10⁹ | O(log n), O(√n), O(1) | binary search on the value, a formula |
Python runs a simple loop step 10 or more times slower, so budget about 10⁷ steps per second. O(n²) is safe up to about 2,000. Built-ins like sorted, sum and set operations run in C, so they’re much faster than the same work written as a Python loop.
Count the loops
Loops one after another add: O(n) + O(n) is still O(n). Loops inside each other multiply: n outer steps times m inner steps is O(n · m).
When the inner bound depends on the outer loop, add up what the inner loop does on each pass:
for i in range(n):
for j in range(i + 1, n): # n-1, then n-2, ... then 0 steps
...
That’s (n − 1) + (n − 2) + … + 0 = n(n − 1)/2. Half of n², but still O(n²): the half is a constant factor.
Two more patterns come up all the time:
- An inner pointer that never goes back. In a sliding window, the inner
whilemovesleftforward. It looks nested, butleftmoves at most n times over the whole run, so the total is O(n). Count the total moves, not the worst case per outer step. - An inner loop that doubles. Starting from
j = 1,while j < n: j *= 2runs about log₂ n times. Inside a loop over n, that’s O(n log n).
The usual way to beat an O(n²) pair loop is to remember something as you go: a set of values seen, a count per value, or a running sum.
Halving gives a logarithm
How many times can you halve n before you reach 1? About log₂ n times. That’s only 10 for a thousand, 20 for a million and 30 for a billion. Whenever each step throws away half of what’s left, expect a log: binary search, the height of a balanced tree or a heap, repeated doubling. The base doesn’t matter in Big-O, because log₂ n and log₁₀ n differ by a constant factor.
Recursion: add up the tree
For recursive code, draw the tree of calls and add up the work in it. Naive Fibonacci:
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
Each call makes two more, and the tree is up to n levels deep, so the number of calls roughly doubles with each level: O(2ⁿ). (The exact growth is about 1.6ⁿ.) fib(40) makes over 300 million calls. Yet only 41 different arguments exist. Store each answer the first time (memoisation) and it drops to O(n).
Merge sort splits the array in half, sorts each half, then merges in O(n). Add up the work one level of the tree at a time:
| Level | Calls | Size of each | Work on the level |
|---|---|---|---|
| 0 | 1 | n | n |
| 1 | 2 | n/2 | n |
| 2 | 4 | n/4 | n |
| k | 2ᵏ | n/2ᵏ | n |
Each level does n work in total, and halving gives log₂ n levels: O(n log n).
Amortised cost: growing an array
A dynamic array (list, vector, ArrayList) keeps spare room at the end. When it’s full, it allocates a bigger block, say twice the size, and copies everything over. That one append costs O(n).
But copies are rare. Filling an array to n items copies 1 + 2 + 4 + … + n/2 items in total, which is less than n. So n appends cost less than 2n steps: O(1) amortised per append. Real libraries grow by between 1.125× and 2×, and any constant factor gives the same result. Amortised is a guarantee over the whole sequence, not a lucky average: no sequence of n appends can cost more.
Space: the call stack counts
Space complexity counts the extra memory your code uses beyond the input. Recursion uses memory too: every call waiting to return keeps a stack frame. A recursion d levels deep uses O(d) space even if it allocates nothing.
- Naive
fib(n)takes O(2ⁿ) time but only O(n) space: only one path of the tree is on the stack at a time. - Merge sort uses O(n) for the merge buffer plus O(log n) of stack.
- A recursive DFS on a path of 10⁵ nodes goes 10⁵ deep. Python stops at 1,000 by default, and deep enough recursion overflows the stack in C++ and Java too. Use an explicit stack.
The output usually doesn’t count as extra space. Say whether you’re counting it.
Why it’s O(n)
The loop runs at most n times. Each pass does one lookup and one insert, both O(1) on average, so the whole function is O(n) time. The set holds up to n values: O(n) extra space. (With a poor hash, or keys chosen to collide, a lookup can degrade to O(n). See hash maps.)
Three ways to solve the same problem, with the steps each takes at n = 2 · 10⁵:
| Approach | Time | Extra space | Steps at n = 2 · 10⁵ |
|---|---|---|---|
| Compare every pair | O(n²) | O(1) | about 2 · 10¹⁰ |
| Sort, then compare neighbours | O(n log n) | depends on the sort | about 4 · 10⁶ |
| Set of values seen | O(n) average | O(n) | about 2 · 10⁵ |
The sort-based version is a fine answer too: it’s still fast, and it avoids hashing. Trading memory for time, as the set does, is the most common move in interviews.
Common mistakes
Building a string with += in a loop
Strings are immutable in Python and Java, so each += can copy the whole string built so far. For n pieces that’s O(n²). CPython sometimes avoids the copy, but don’t rely on it. In C++, s += c is fine, but s = s + c copies.
s = ""
for w in words: s += w # ✗ can copy s every time: O(n²)
s = "".join(words) # ✓ one pass: O(n)
in on a list
x in lst scans the list, so it’s O(n). Inside a loop it quietly turns O(n) into O(n²). A set answers the same question in O(1) on average.
common = [x for x in a if x in b] # ✗ b is a list: O(len(a) · len(b))
b_set = set(b)
common = [x for x in a if x in b_set] # ✓ O(len(a) + len(b))
Slicing copies
a[1:], s[i:j] and a[::-1] all build a new list or string: O(length of the slice). Taking a[1:] again and again copies n − 1, then n − 2, … items: O(n²). Recursing on a[1:] does the same. Pass indices instead.
while a: x, a = a[0], a[1:] # ✗ copies the rest every time: O(n²)
for x in a: ... # ✓ O(n)
Hidden sorting and scanning
One call can hide a lot of work. sorted() is O(n log n), and min, max, sum, .index() and .count() are O(n) each. Called once per loop step, they multiply. So does list.pop(0), which shifts every item: use collections.deque.
for x in stream: vals.append(x); top = sorted(vals)[-1] # ✗ O(n² log n)
for x in stream: top = max(top, x) # ✓ O(n)
Variations
- Best, worst and average case. Big-O usually means the worst case. Hash lookups are the common exception, quoted as O(1) on average. Say which one you mean.
- Amortised against average. Amortised is a guarantee over any sequence of operations. Average is over random inputs, and one unlucky input can still be slow.
- More than one size. A graph is O(V + E), a grid O(rows · cols), two strings O(n · m). Keep the separate letters: collapsing them into one n hides real differences.
- Output-sensitive bounds. Listing every subset takes at least O(2ⁿ · n), because the output itself is that big. No clever trick beats the size of the answer.
- Big-Θ and Big-Ω. O is an upper bound, Ω a lower bound, and Θ both at once. In interviews “O(n)” usually means the tight bound. A classic lower bound: any sort that only compares items needs Ω(n log n) comparisons.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
The limits say
n ≤ 2 · 10⁵. Your first idea checks every pair of elements. What should you do?At 10⁸ simple steps a second, 4 · 10¹⁰ steps take minutes. A faster language buys a constant factor of maybe 10, not the 400 you’d need. O(log n) is overkill: you have to read all n values anyway, and O(n log n) is about 4 · 10⁶ steps.
-
2
What does this print?
n = 6count = 0for i in range(n):for j in range(i + 1, n):count += 1print(count)The inner loop runs 5, 4, 3, 2, 1, 0 times: n(n − 1)/2 = 15, once for every pair. That’s still O(n²); the half is a constant factor. 36 would be n² (both loops over all of
range(n)), and 21 would includej == i. -
3
What does this print?
n, steps = 1000, 0while n > 1:n //= 2steps += 1print(steps)1000, 500, 250, 125, 62, 31, 15, 7, 3, 1: nine halvings. That’s ⌊log₂ 1000⌋, since log₂ 1000 ≈ 9.97. 10 is tempting because 2¹⁰ = 1024, but floor division reaches 1 one step sooner. 3 would be log₁₀ 1000, which is the same up to a constant factor, but it isn’t what this loop counts.
-
4
This keeps the first copy of each value, in order. It’s correct, but it runs out of time when
len(nums)is 10⁵. Why?def first_copies(nums):out = []for v in nums:if v not in out:out.append(v)return outMembership in a list is a linear scan, and it happens once per value: up to n · n / 2 comparisons when values are distinct. A set answers
v in seenin O(1) on average.appenddoes sometimes copy, but growth by a constant factor makes it O(1) amortised. -
5
What is the extra space of this function, and what happens in Python on a list of 10⁵ numbers?
def total(a, i=0):if i == len(a):return 0return a[i] + total(a, i + 1)Each call is waiting for the next one to return, so n frames sit on the stack at once: O(n) space even though no list is built. Python doesn’t eliminate tail calls (and this isn’t a tail call anyway: the addition happens after the call returns). A loop with a running total uses O(1) space.
Practice problems
Solve these right here, in Python, C++ or Java. Tests run as you go.