Complexity Analysis & Big(O)

Why do I need to know this?

This was a question I grappled with for a while. Eventually, I came to realize that the reality of the situation is that, even though you will almost certainly NOT be using complexity analysis in your day to day, you need some way to represent these abstract ideas, even if you recognize, mentally, that some ways of writing code are just better (and more clever) than others.

What counts as a step?

A step is one primitive operation, something the computer does in a fixed amount of time no matter how big the input is. Assigning a variable, doing arithmetic, comparing two values, indexing into an array, and returning from a function all count as one step each. Whether sum += i is one step or two is a convention, so pick one & stay consistent.

The only thing worth memorizing is the for loop. for (int i = 0; i < n; i++) costs 2n + 2 steps before the body even runs: the i = 0 happens once, the i < n check happens n + 1 times (n times it’s true, once it’s false), and the i++ happens n times. That’s 1 + (n + 1) + n.

C++
int func1(int n) {
  int sum = 0;                    // 1 step
  for (int i = 0; i < n; i++) {   // 2n + 2 steps
    sum += i;                     // 1 step, n times = n
  }
  return sum;                     // 1 step
}
// total: 1 + (2n + 2) + n + 1 = 3n + 4

Nest a loop inside of it and the inner loop’s cost gets paid on every pass of the outer one:

C++
int func2(int n) {
  int sum = 0;                      // 1 step
  for (int i = 0; i < n; i++) {     // 2n + 2 steps
    for (int j = 0; j < n; j++) {   // n * (2n + 2) = 2n² + 2n steps
      sum++;                        // n * n = n² steps
    }
  }
  return sum;                       // 1 step
}
// total: 1 + (2n + 2) + (2n² + 2n) + n² + 1 = 3n² + 4n + 4

Those exact numbers depend on what you decided counts as a step, and someone else’s convention would land on a slightly different answer. That’s fine, because the next section throws most of it away. All that survives is that func1 grows linearly and func2 grows quadratically.

The deets

First, common orders of functions (top down, from best-worst)

NotationNameDefinition
O(1)O(1)ConstantThe cost doesn’t change with the input. 10 elements or 10 million, same amount of work.
O(log⁡n)O(\log n)LogarithmicEach step cuts what’s left by a fraction (usually half), so doubling the input only adds one more step.
O(n)O(n)LinearThe cost scales directly with the number of elements. Twice the input, twice the work.
O(nlog⁡n)O(n \log n)Loglinear, Linearithmic, n log nA log⁡n\log n amount of work for each of the nn elements. This is what a good comparison sort costs.
O(n2)O(n^2)QuadraticFor every element, you go through every element again (a loop inside a loop). Twice the input, four times the work.
O(n3),O(n4),…O(n^3), O(n^4), \ldotsPolynomialSame idea with more nested loops: nkn^k for some fixed kk. Every extra loop multiplies the work by another nn.
O(cn)O(c^n)ExponentialEvery extra element multiplies the work by a constant cc, like trying every subset of the input (2n2^n of them). One more element doubles the work.
O(n!)O(n!)FactorialTrying every possible ordering of the elements. n=10n = 10 is already about 3.6 million orderings.
O(22n)O(2^{2^n})Doubly ExponentialThe exponent itself grows exponentially. n=5n = 5 is already over 4 billion, and you’ll rarely see it outside of theory.
growth of the common orders

The relationship that complexity analysis is trying to create is that which tells you how an algorithm’s time/space scales as input grows. In other words, when looking at an algorithm, you’re measuring two main things: the time it takes for the algorithm to complete, and how much space the algorithm needs.

What we can drop & ignore

During complexity analysis, you can make a couple of big leaps.

You can drop constants from equations.

For example: 3n23n^2 + 7n7n + 42 = O(n2)O(n^2)

f(n)f(n) = 3n23n^2 + 7n7n + 42

g(n)g(n) = O(n2)O(n^2)

g(n) is a simpler function you pick to stand in for the growth of f(n). It isn’t the growth itself.

You can drop constants because complexity analysis measures growth. I.e. how the cost grows as the input gets huge, and a constant factor doesn’t change that growth. It only changes the starting height.

Example: Say algorithm A takes 100n steps and algorithm B takes n² steps.

100n vs n²
nA (100n)B (n²)
101,000100
10010,00010,000
1,000100,0001,000,000
1,000,000100,000,0001,000,000,000,000

B wins at small sizes, they tie at 100, and after that A wins by a wider margin every time. No constant you put in front of n can stop n² from catching up eventually. If you changed A to 1000n, the crossover would just move to n = 1000. The shape of the curves is what decides who wins in the end.

There are a couple of logistical reasons we can do this:

  • Constants depend on the several parts of the machine that are irrelevant to the goal of Big-O, which is only meant to describe the algorithm, not your hardware.
  • Constants are a moving target. One “step” might be one instruction or ten, depending on how you count. Counting can give a different answer for every counting convention, and the growth rate is the only thing they all agree on.
  • Doubling the input: With 5n, doubling the input doubles the work. With 5n², it quadruples the work. The 5 never shows up in that comparison.

Ignore lower order terms

n2+n+1=O(n2)n^2 + n + 1 = O(n^2)

n2+log⁡n+1=O(n2)n^2 + \log n + 1 = O(n^2)

Lower order terms get dropped because they stop mattering. Here’s 3n2+7n+423n^2 + 7n + 42 as n grows, and how much of the total is just the n2n^2 term:

n3n23n^27n+427n + 42share from 3n23n^2
1030011272.8%
10030,00074297.6%
1,0003,000,0007,04299.8%
1,000,0003,000,000,000,0007,000,04299.9998%

By the time n is a million, everything except the n2n^2 term is a rounding error. To figure out which term is the highest order, use the table of orders from earlier: whichever one is lower down in the table wins. So n2n^2 beats nlog⁡nn \log n beats nn beats log⁡n\log n beats 11.

Ignore the coefficient of the highest order term

3n2+7n+42=O(n2)3n^2 + 7n + 42 = O(n^2)

That 3 is a constant multiplier, so it goes for the same reason as above. Just be careful about where the constant is. It only disappears when it’s multiplied out front. If it’s in an exponent or a base, it stays: 2n2^n and 3n3^n are different orders of growth, and so are n2n^2 and n3n^3.

Summary

Generally, for exams, having log & power identities will be extraordinarily helpful in order to see how different complexities are actually equivalent to each other.

Log Identities

IdentityExample
log⁡a(xy)=log⁡ax+log⁡ay\log_a(xy) = \log_a x + \log_a ylog⁡2(3⋅4)\log_2(3 \cdot 4)
log⁡a(xy)=log⁡ax−log⁡ay\log_a\left(\frac{x}{y}\right) = \log_a x - \log_a ylog⁡2(4/3)\log_2(4/3)
log⁡a(xr)=rlog⁡ax\log_a(x^r) = r \log_a xlog⁡2x3\log_2 x^3
log⁡a(1x)=−log⁡ax\log_a\left(\frac{1}{x}\right) = -\log_a xlog⁡21/3\log_2 1/3
log⁡ax=log⁡xlog⁡a=ln⁡xln⁡a\log_a x = \frac{\log x}{\log a} = \frac{\ln x}{\ln a}log⁡21024\log_2 1024
log⁡aa=1\log_a a = 1
log⁡a1=0\log_a 1 = 0

Power Identities

IdentityExample
a(m+n)=am⋅ana^{(m+n)} = a^m \cdot a^n22+32^{2+3}
a(m−n)=amana^{(m-n)} = \frac{a^m}{a^n}23−22^{3-2}
(am)n=amn(a^m)^n = a^{mn}(22)3(2^2)^3
a−n=1ana^{-n} = \frac{1}{a^n}2−42^{-4}
a−1=1aa^{-1} = \frac{1}{a}
a0=1a^0 = 1
a1=aa^1 = a

Big-O, Big-Θ, and Big-Ω

Big-O, Big-Θ, and Big-Ω are three ways to bound a function. A bound is just a simpler function that sits above, below, or right on top of yours once n is big enough.

NotationBoundDefinition (for all n≥n0n \ge n_0)
f(n)=O(g(n))f(n) = O(g(n))Upperf(n)≤c⋅g(n)f(n) \le c \cdot g(n)
f(n)=Ω(g(n))f(n) = \Omega(g(n))Lowerf(n)≥c⋅g(n)f(n) \ge c \cdot g(n)
f(n)=Θ(g(n))f(n) = \Theta(g(n))Tightc1⋅g(n)≤f(n)≤c2⋅g(n)c_1 \cdot g(n) \le f(n) \le c_2 \cdot g(n)

The cc‘s and n0n_0 are positive constants that you get to pick. The cc is the “drop constants” rule, since any multiplier gets absorbed into it, and n0n_0 is the “big enough” part, since f(n)f(n) is allowed to break the rule for small inputs.

Example with f(n)=3n2+7n+42f(n) = 3n^2 + 7n + 42:

  • Upper: for n≥1n \ge 1, 7n≤7n27n \le 7n^2 and 42≤42n242 \le 42n^2, so f(n)≤52n2f(n) \le 52n^2. Pick c=52c = 52 and n0=1n_0 = 1, which makes f(n)=O(n2)f(n) = O(n^2).
  • Lower: f(n)≥3n2f(n) \ge 3n^2 for every nn, so pick c=3c = 3 and n0=1n_0 = 1, which makes f(n)=Ω(n2)f(n) = \Omega(n^2).
  • Tight: it’s both, so f(n)=Θ(n2)f(n) = \Theta(n^2).

It’s also true that f(n)=O(n3)f(n) = O(n^3) and f(n)=Ω(n)f(n) = \Omega(n), since an upper bound only has to be above you and a lower bound only has to be below you. They’re just useless statements, so by convention you give the tightest bound you can.

Everyone says Big-O when they mean Big-Θ. That’s fine in conversation, but on an exam, use whichever one the question actually asks for. Also, Big-O does NOT mean “worst case”. It’s a bound on a function. Which function you’re bounding is a separate question, and it’s the next section.

Best, worst, and average case

The bounds above describe a function, but the cost of an algorithm often depends on which input of size n you hand it, not just how big it is. Best, worst, and average case are about which input you got.

C++
int linear_search(const std::vector<int>& vec, int target) {
  for (int i = 0; i < vec.size(); i++) {
    if (vec[i] == target) { return i; }
  }
  return -1;
}
CaseWhere the target isSteps
BestThe first elementO(1)O(1)
AverageAround the middleabout n/2n/2, so O(n)O(n)
WorstThe last element, or not there at allO(n)O(n)

Each case is its own function, so each one gets its own OO, Ω\Omega, and Θ\Theta. The best case of linear search is Θ(1)\Theta(1) and the worst case is Θ(n)\Theta(n). When someone gives you a complexity with no case attached, they almost always mean worst case. The same goes for duplicate_nested below: best case is O(1)O(1) (the first two numbers match), worst case is O(n2)O(n^2).

Average case needs an assumption about what inputs are likely, like “the target is equally likely to be anywhere.” Don’t confuse it with amortized complexity, which is next.

true or false

Big-O means worst case.

Amortized complexity

Some operations are cheap almost every time and expensive once in a while. Amortized complexity is the cost per operation once you spread the expensive ones across the cheap ones.

Think of a cell phone bill. Say it’s $50 a month, except in January you also buy a $600 phone. The bill is spiky, but over the year you paid $1,200 for 12 months, so it works out to $100 a month. That $100 is the amortized cost.

The classic example is std::vector::push_back. Most pushes are O(1): drop the element in the next free slot. When the array is full, though, it has to allocate a bigger one and copy all n elements over, which is O(n). How much bigger decides everything:

Growth strategyTotal copying over n pushesAmortized per push
Add 1 slot each timeabout n2/2n^2 / 2O(n)O(n)
Add a constant cc slotsabout n2/2cn^2 / 2cO(n)O(n)
Double the capacityless than 2n2nO(1)O(1)

Doubling wins because after a copy of k elements, you get k cheap pushes before the next resize. The expensive copy gets paid off by the cheap pushes that follow it, same as the phone. Adding a fixed amount doesn’t work, because the copies keep getting bigger while the gap between them stays the same.

true or false

If push_back is amortized O(1), every single push_back takes constant time.

Amortized is a guarantee about a sequence of operations, whatever they are. Average case is about a typical input and leans on probability.

Complexity analysis in action

For example, let’s take a really simple problem: “Is there a duplicate number in my array?” As simple as this question is, the consequences for how you solve this problem can be vast. I’ll get into the nitty gritty of Big O later, so just bear with me through this example:

Text
Vector: [1, 2, 3, 4, 5]

cursor1 = 0, checks against:
   v
  [1, 2, 3, 4, 5]
      ^  ^  ^  ^  (cursor2 goes 1→4)

cursor1 = 1, checks against:
      v
  [1, 2, 3, 4, 5]
         ^  ^  ^  (cursor2 goes 2→4)

cursor1 = 2, checks against:
         v
  [1, 2, 3, 4, 5]
            ^  ^  (cursor2 goes 3→4)

As code, this comes out as:

C++
bool duplicate_nested(std::vector<int> vec) {
  for(int cursor1 = 0; cursor1 < vec.size(); cursor1++) {
    for (int cursor2 = cursor1 + 1; cursor2 < vec.size(); cursor2++)
      if (vec[cursor1] == vec[cursor2]) { return true; }
  }
  return false;
}

While this is a valid way to solve the problem, it’s actually one of the worst ways to do so. Why? Because for every item in the array, you’re checking it against every other item in the array. This means that if you have 10 items, you could be doing up to 100 checks. If you have 100 items, you could be doing up to 10,000 checks. If you have 1,000 items, you could be doing up to 1,000,000 checks. This is a classic example of an algorithm with O(n²) time complexity.

“So, what’s a better way?” Your Grandmother asks at the dinner table. Great question, Grandma! A better way to solve this problem is to use a data structure that A) Doesn’t allow duplicates, and B) Has fast lookup times. A std::unordered_set in C++ is perfect for this. Here’s how you could implement it:

C++
bool duplicate_simple(std::vector<int> vec) {
  std::unordered_set<int> seen_values;
  for (int nums : vec) {
    if (seen_values.count(nums)) { return true; }
    seen_values.insert(nums);
  }
  return false;
}

#whatdatmean

When you insert a number, it runs it through a hash function that converts it to an index (like hash(42) = 7). It stores the number at that index in an internal array. We’ll come back to hashing way later, but all you need to know for now is that when you check if (seen.count(num)), it hashes num again, jumps directly to that index, and checks if it’s there.

Because of allllllat, this works out to about O(n) time complexity, because for each of the n items in the array, you’re doing a constant time operation (hashing and checking/inserting into the set).

If you want to be a fucking chad, you could also just set a vector to a set & compare the sizes:

C++
bool duplicate_cast(vector<int>& nums) {
  unordered_set<int> s(nums.begin(), nums.end());
  return s.size() != nums.size();  // if sizes differ: dupes exist
}

This method also runs in O(n) time, but is just cleaner code. It’s not necessarily better for space, since you’re still using extra space for the set, but it’s a nice one-liner.

Measuring it for real

Everything above is a prediction. You can check it by running the code and timing it with std::chrono:

C++
#include <chrono>

auto start = std::chrono::steady_clock::now();
duplicate_nested(vec);
auto stop = std::chrono::steady_clock::now();

auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(stop - start).count();

Use steady_clock, since it can’t jump backwards the way the system clock can. Then compare against the prediction: if duplicate_nested is O(n²), doubling n should make it take about 4x as long. If it’s O(n), about 2x. If you doubled n and the time barely moved, or went up by 8x, either your analysis or your code is wrong.

A few things will ruin your measurements. Compile with optimizations on (-O2), but make sure you actually use the result, because the compiler is allowed to delete work that has no effect. Run it several times, since other programs on your machine add noise. Use inputs big enough to take more than a few milliseconds, or you’re just timing the clock.

When the numbers disagree with your prediction, two tools help you figure out why:

  • perf shows where your program spends its time, down to the function (perf record, then perf report).
  • valgrind has a few tools. callgrind counts instructions instead of seconds, so it gives the same answer every run. massif shows how much heap memory you’re using over time, which is the space half of complexity analysis.