Time Complexity Analysis
Throughout this course you'll see claims like "push_back is amortized O(1)" or "hash table lookups
are O(1) on average" or "this graph algorithm runs in O(E log E)." This primer defines what that
notation means and shows you how to derive it yourself. Treat it as a reference: read it once before
the data-structures block (Lectures 7 onward), then come back whenever a lecture cites a complexity
you want to understand.
Why We Analyze Time Complexity
We want to compare algorithms without tying ourselves to a specific machine, compiler, or input. A fast laptop running a bad algorithm will eventually lose to a slow one running a good algorithm — you just need a large enough input. So instead of measuring wall-clock time, we count how the number of primitive steps grows as the input grows.
Let n be the size of the input — the length of a vector, the number of nodes in a tree, the number
of edges in a graph. A primitive step is anything that takes constant time regardless of n: an
addition, a comparison, an array index, a pointer dereference.
This function performs roughly n + 2 steps. As n grows, the + 2 stops mattering. What matters
is that the work grows linearly with n. We capture that with Big-O notation: this is O(n).
Asymptotic Notation
Asymptotic notation describes how a function behaves as its input tends to infinity. The three you will encounter:
- Big-O — is an upper bound. The algorithm takes at most this much time.
- Big-Omega — is a lower bound. The algorithm takes at least this much time.
- Big-Theta — is a tight bound: both an upper and lower bound. The algorithm takes exactly this much time, up to constant factors.
In practice, "complexity" almost always means Big-O, because we care most about the worst case and the guarantee it gives us. This primer uses O-notation throughout, but keep in mind that when we say a loop "is O(n)" we usually mean it is tightly O(n) — that is, .
The two rules that make it work
Big-O lets us throw away detail that doesn't affect the growth rate:
- Drop constant factors. , , and are all just . A constant multiplier doesn't change how the cost scales.
- Drop lower-order terms. is . For large
n, the term dominates so thoroughly that the is noise.
The formal definition: if there exist positive constants and such that for all . In words: past some input size, never exceeds a constant multiple of . The two rules above are just this definition applied.
Common Complexity Classes
These are the growth rates you will see again and again, from fastest to slowest:
| Complexity | Name | Example |
|---|---|---|
| constant | array index, hash lookup, push_back (amortized) | |
| logarithmic | binary search, balanced BST lookup | |
| linear | scanning a vector, linear search | |
| linearithmic | merge sort, std::sort | |
| quadratic | nested loops, bubble sort | |
| cubic | naive matrix multiply | |
| exponential | generating all subsets | |
| factorial | generating all permutations |
The gap between these classes is enormous. For , an algorithm does a million steps while an algorithm does a trillion — the difference between instant and unusable.
A useful instinct for interviews and this course: if n can be up to a few million, you need
or . If n is at most a few thousand, is usually fine. is
only viable when n is tiny (around 20 or less).
Analyzing Iterative Code
Most analysis comes down to a few mechanical rules.
Sequential blocks add
When one block of work follows another, you add their costs — then drop the smaller one.
Nested loops multiply
When one loop runs inside another, you multiply their iteration counts.
Watch the bounds carefully — they don't always multiply to the obvious answer:
Halving is logarithmic
When a loop divides the problem size each iteration instead of stepping through it, the count is
logarithmic. You can only halve n about times before reaching 1.
Hidden loops count too
Standard-library calls are not free. A call inside your loop hides a loop of its own.
Know the cost of what you call. std::sort is , std::find and std::count are
, and inserting at the front of a std::vector is because every later element shifts.
A single innocent-looking call can dominate your whole function.
Best, Average, and Worst Case
The same algorithm can take different amounts of time on different inputs of the same size. Consider linear search:
- Best case — the target is the first element. One comparison: .
- Worst case — the target is last or absent.
ncomparisons: . - Average case — over random inputs, about
n/2comparisons: still .
When a lecture says an operation "is O(n)" without qualification, it almost always means the worst case, because that is the guarantee you can rely on. The exceptions are operations described as "average" (like hash table lookups, which degrade to in the worst case) and "amortized" — the subject of the next section.
Amortized Analysis
Some operations are usually cheap but occasionally expensive. Amortized analysis averages the cost over a long sequence of operations, so a rare expensive step is paid for by the many cheap ones around it. This is different from average case: amortized cost is a guarantee over any sequence, not an expectation over random inputs.
The canonical example is std::vector::push_back, which you met in
Lecture 7.
When the vector runs out of capacity, it allocates a larger block (typically double the size) and
copies every existing element over — that single push_back costs . But because the capacity
doubles, those expensive copies happen rarely: at sizes 1, 2, 4, 8, 16, and so on.
Summing the copy work across n insertions gives total copies. Spread
over n calls, that's at most 2 copies per call — a constant. Hence amortized O(1) per
push_back, even though a single call can spike to .
The technique above is the aggregate method: bound the total cost of a sequence of n
operations, then divide by n. You'll see "amortized O(1)" again for hash tables
(Lecture 15) and near-constant for
union-find (Lecture 21).
Analyzing Recursive Code
Loops are easy to count by inspection. Recursion needs a different tool: a recurrence relation, an equation that expresses the running time in terms of the time on smaller inputs.
Start with a simple linear recursion:
Each call does work and makes one call on a problem of size n - 1. That gives the
recurrence:
The substitution method
To solve a recurrence, unroll it until you reach the base case and look for the pattern:
After n unrollings we hit the base case, so . The recursion is linear — which makes
sense, since it's just a loop wearing a disguise.
Recursion that branches grows faster. Naive Fibonacci makes two calls per level:
This gives , which unrolls into a call tree of roughly nodes — exponential, . That blow-up is exactly why we reach for memoization or dynamic programming.
The Master Theorem
Divide-and-conquer algorithms split a problem into equal-sized subproblems, solve them recursively, and combine the results. Their recurrences all share a shape, and the Master theorem solves that shape directly without unrolling.
For a recurrence of the form
where subproblems each have size , and is the cost to split and combine, compare against :
| Case | Condition | Result |
|---|---|---|
| 1 | grows slower than | |
| 2 | grows like | |
| 3 | grows faster than |
The term measures the cost of all the leaves in the recursion tree; measures the cost at the root. The theorem just asks which one dominates.
Worked examples
Binary search halves the input and does work per level, so , , :
Here , and matches it — Case 2 — giving .
Merge sort splits into two halves and merges in linear time, so , , :
Here , and matches it — Case 2 — giving the famous . The recursion tree makes this visible: each level does total work, and there are levels.
The Master theorem only applies when subproblems are the same size (). It does not help with recurrences like (Fibonacci) or (uneven splits). For those, fall back to the substitution method or a recursion tree.
Space Complexity
The same counting applies to memory. Space complexity measures how much extra memory an algorithm
needs as a function of n, usually split into:
- Auxiliary space — extra memory beyond the input itself.
- Total space — auxiliary space plus the input.
Two common sources of space cost:
Recursion costs space too: every pending call occupies a frame on the call stack. The sum_to
function earlier reaches a recursion depth of n, so it uses stack space even though it
allocates nothing on the heap. Merge sort's recursion depth is .
This is the other axis of the array-vs-linked-list trade-off from Lecture 18: the time table there has a space companion, since pointers in a linked list cost memory that a contiguous array doesn't.
Cheat Sheet
Map the code pattern to its complexity at a glance:
| Pattern in code | Complexity |
|---|---|
Single pass over n elements | |
Two nested loops over n | |
Loop that halves n each step | |
| Sort, then a single pass | |
| Hash table insert / lookup | amortized |
std::vector::push_back | amortized |
| Insert / erase at front of a vector | |
| Binary search on a sorted vector | |
| Two-pointer sweep over a sorted array | |
| Divide in two, linear combine () | |
Generate all subsets of n items | |
Generate all permutations of n items |
Exercises
Q1.
State the worst-case time complexity of each snippet in Big-O notation, in terms of n.
Q2.
The loop bound depends on the outer variable. Count the total number of times do_work() runs, then
give the Big-O complexity.
Q3.
Write the recurrence relation for each recursive function, then solve it.
Q4.
Apply the Master theorem to each recurrence. Identify a, b, f(n), the case, and the result.
Q5.
A colleague claims their function is because "it's just one push_back in a loop." Explain in
your own words why a single push_back can cost , yet a loop of n push_backs is still
overall and not .
Practice Problems
- Binary Search — Easy
- Two Sum — Easy
- Merge Intervals — Medium