03 · Data Structures & Algorithms

Big-O Notation

7 min read · 5 questions · 1 exercise

Big-O describes how the work an algorithm does grows as its input grows, which is a different question from how fast the code runs today.

The duplicate-order check had been fine for eighteen months. On every save it walked the customer’s orders looking for two with the same id, found none, and came back in a blink. Then one customer imported a back catalog — four thousand orders where the busiest account had ever held forty — and saving started to time out. Nobody had touched that function. The list got a hundred times longer, and the check got about ten thousand times slower.

The distance between “a hundred times more data” and “ten thousand times more work” is what Big-O notation exists to name. It does not measure how fast your code runs. It describes how the work grows when the input does, and those are different enough questions that the second one is the one that pages you at 3am.

Growth, not speed

Picture a door person checking arrivals against a guest list. Ask how long one check takes and you get an answer about that person, that clipboard, that evening. Ask what happens when the list goes from forty names to four thousand and you have learned something about the method, which a faster door person cannot change. Big-O is the second question.

So the notation throws away everything the second question does not depend on. Constant factors go: a method that takes 3n + 50 steps and one that takes n / 2 are both O(n), because both draw a straight line, and a straight line is a straight line however steep. Lower-order terms go with them — n² + 100n is O(n²), because once n is large enough the square swamps everything beside it. Put numbers in and you can watch it happen: at n = 10 the 100n term is ten times the square, 1,000 against 100; at n = 100 they tie at 10,000 each; at n = 10,000 it is 1,000,000 against 100,000,000, a rounding error.

What survives is the shape, and the shape is the durable part. It outlives the language you wrote it in, the machine you deployed it to, and the compiler that got better over the winter. Doubling your hardware moves a constant. It cannot turn a square into a line.

The shapes you will meet

Only a handful come up in ordinary work. Constant time, O(1), is work that does not depend on n at all: looking a key up in a hash table, reading array[7], pushing onto a stack. The door person has a pre-printed badge for each name and simply hands it over. Hash lookups are O(1) amortized, and only with a hash that spreads keys evenly — a bad one degrades toward a scan. Logarithmic time, O(log n), throws away half of what is left on every step: binary search over a sorted array, a lookup through a database index. Doubling n adds one step rather than doubling the steps, so a million items is twenty steps and a billion is thirty. That is the shape that makes large data tractable at all.

Linear time, O(n), is one unit of work per item — summing an array, filtering a list, reading a file. The door person reads the list top to bottom; twice the guests, twice the reading. Just above it sits O(n log n), which is the best a general-purpose comparison sort can do and what Array.prototype.sort gives you. It is close enough to linear that it rarely decides an argument.

Then there is quadratic time, O(n²): every item measured against every other item, which in code is almost always two loops with one nested inside the other. The door person asks each guest whether they know each other guest. This is the shape that ambushes people, because at forty items it is sixteen hundred units of work — imperceptible — and at four thousand items it is sixteen million.

The scene below runs three of those shapes over one order, and then doubles the list three times.

Predict first

The scene starts at one order, where all three bars read 1: an O(1) lookup, an O(n) scan, and an O(n²) check of every pair. It then doubles the list three times, to 2, 4 and 8 orders. What do the three bars read at 8 orders?

  1. That has the quadratic bar doubling each time the list does, which is how 2n grows, not n². Squaring a doubled n multiplies it by four: 4, 16, then 64.

  2. 28 is the number of distinct pairs, n(n − 1) ÷ 2, which is what a loop comparing each order only with the ones after it would run. Counted the way this page counts it, each order against each order, it is 8 × 8 = 64. Both are O(n²).

  3. Building the set touches every order, but that happens once, before any lookup. The bar counts the lookup itself, which hashes one id and opens one bucket at any size.

  4. The lookup never moves, the scan grows with n, and the pair check grows with n²: 8 × 8 = 64. Across the three doublings the scan went 2, 4, 8 and the pairs went 4, 16, 64, four times the work for every doubling.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

One doubling list, three costs

Bar length is operations · same three algorithms, one doubling list

O(1)1
O(n)1
O(n²)1

One order: all three algorithms do one operation — use Next step to double the list.

Each bar is one algorithm’s operation count, drawn against the same scale, so a bar twice as long is twice the work. At four orders they read 1, 4 and 16 — exactly what the three functions below do. Put a thousand orders in and the same three numbers become 1, one thousand, and one million.

Where the notation stops helping

Big-O is a statement about behavior as n grows without bound, which makes it deliberately silent about the n you actually have.

Constants matter in practice. Building a Set allocates memory and hashes every key; scanning an eight-element array touches eight neighboring slots and is finished before the hash function has warmed up. For small, fixed n an O(n) scan routinely beats an O(1) lookup, and the honest answer there is to measure rather than to reason from the notation. What the notation buys you is the other end: wherever the crossover sits, past it the scan loses and goes on losing.

It also hides which case you are being told about. Quicksort is O(n log n) on average and O(n²) in its worst case; a hash lookup is O(1) until every key collides in one bucket. When someone quotes a complexity, ask which case — and for anything holding input a stranger controls, the worst case is the one they get to choose.

It says nothing about memory either. An algorithm can trade one for the other, which is exactly what the Set below does: it spends O(n) space to buy O(1) lookups. Space complexity is written the same way and deserves to be stated alongside the time.

Here are the three functions the scene runs, at the middle size the scene passes through:

interface Order {
  id: string;
}

const orders: Order[] = [{ id: 'o1' }, { id: 'o2' }, { id: 'o3' }, { id: 'o4' }];
const ids = new Set(orders.map((order) => order.id));

/** One hash, one bucket: 1 operation at n = 4 and at n = 1,000,000. */
function hasId(id: string): boolean {
  return ids.has(id);
}

/** One comparison per order, so 4 when the id is absent: n operations. */
function findById(list: Order[], id: string): Order | undefined {
  for (const order of list) {
    if (order.id === id) return order;
  }
  return undefined;
}

/** Every ordered pair, so 4 × 4 = 16 iterations here: n² operations. */
function firstRepeatedId(list: Order[]): string | undefined {
  for (const a of list) {
    for (const b of list) {
      if (a !== b && a.id === b.id) return a.id;
    }
  }
  return undefined;
}

Count the loop bodies and you have the middle round of the scene: one, four, sixteen. The fix for firstRepeatedId is the trade hasId already made — keep a Set of the ids seen so far and check each order against it once. That turns sixteen into four here, and sixteen million into four thousand at the size that broke the save button.

Try it

Make the duplicate check linear

Rewrite firstRepeatedId so it reads each order once, making the trade hasId already made. On the opening’s back catalog of 4,000 orders with no repeats, it should make 4,000 checks where the nested loops made 16,000,000.

Hint

Walk the list once and keep a Set of the ids you have already passed. For each order there are two calls to make, has and add, and the order you make them in decides whether a repeat is ever found.

Solution
function firstRepeatedId(list: Order[]): string | undefined {
  const seen = new Set<string>();
  for (const order of list) {
    if (seen.has(order.id)) return order.id;
    seen.add(order.id);
  }
  return undefined;
}

Each order costs one has and one add, both O(1) on average, so the whole check is O(n), paid for with O(n) memory for the set. Asking before adding is the point: add first, and every order would find its own id.

One thing changed besides the speed. On a list with two different repeats, such as o2, o1, o1, o2, the nested loops return o2, the first order with a twin anywhere, and this version returns o1, the first id to turn up twice. As a duplicate check the two agree; if a caller depends on which id comes back, a rewrite for speed is the moment to find out.

To see it work, on the back catalog from the opening:

const backCatalog: Order[] = Array.from({ length: 4000 }, (_, i) => ({
  id: `o${i + 1}`,
}));
console.log(firstRepeatedId(backCatalog)); // undefined, after 4,000 checks
backCatalog.push({ id: 'o42' });
console.log(firstRepeatedId(backCatalog)); // o42

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

A duplicate check that compares every pair of items takes 2 ms on a cart of 100 items. A bulk import sends a cart of 1,000. About how long does the check take now?

  1. The constant is already inside your measurement: 2 ms at 100 items includes it. What Big-O adds is how that number scales, and that is enough for an estimate.

  2. Ten times the items, ten times the time is the linear intuition. Every-pair work grows with the square of the input: (1,000 ÷ 100)² = 100.

  3. Ten times the items is a hundred times the pairs, (1,000 ÷ 100)² = 100, so 2 ms becomes about 200 ms. It is the opening story in miniature: the input grew tenfold and the check a hundredfold.

  4. That is a thousandfold increase, which ten times the input produces only for O(n³) work, three nested loops. Two nested loops give a hundredfold.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

A function links each order to its parent by running orders.find((o) => o.id === order.parentId) once for every order in orders. There is only one for loop in the function. How does its work grow with the number of orders?

  1. find reads the array from the front until something matches, up to n comparisons, and it runs once per order: n × n. At 4,000 orders that is up to 16 million comparisons. Build a Map from id to order first and each lookup becomes O(1), the whole function O(n).

  2. Counting the for keywords misses the loop inside find. A method call that scans an array is a loop, whatever it is called.

  3. Being built in makes each comparison quick, not fewer of them. find has no index to jump through, so it scans; O(n log n) is what a good sort costs, not what a search costs.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

A binary search over 1,000,000 sorted order ids takes about 20 steps. The list grows to 4,000,000 ids. About how many steps does a search take now?

  1. Logarithmic growth is slow, not zero. Each doubling of the list adds one step, and this list doubled twice.

  2. 4,000,000 is 1,000,000 doubled twice, and each doubling costs one more halving: about 22 steps. Four times the data for 10% more work is what makes O(log n) so valuable.

  3. Twice the steps for four times the data would be square-root growth. Halving the range on every step means one extra step per doubling, so two extra here.

  4. Four times the steps for four times the data is linear growth, which is a scan. Binary search discards half of what is left on every step.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Every request checks a flag against a fixed array of 6 enabled feature flags with includes. A reviewer asks you to switch to a Set, because O(1) beats O(n). What is the right call?

  1. Big-O promises that past some size the O(1) lookup wins and keeps winning. It says nothing about where that size is, and six fixed items may well sit below it.

  2. The reverse. A Set is a hash table, with buckets and spare room to keep chains short; it spends memory to buy fast lookups, the trade this page describes.

  3. Big-O describes growth, and this list cannot grow: it is 6 by design. At that size constants decide, and includes over six neighboring slots can beat hashing the string first. Measure, and revisit if the list ever stops being fixed.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Key takeaways

  • Big-O describes how work grows with the input rather than how fast the code runs, which is why it survives a hardware upgrade.

  • Constants and lower-order terms are dropped on purpose; what is left is the shape, and the shape is what decides how the code behaves at scale.

  • The gap between O(n) and O(n²) is invisible at forty items and fatal at forty thousand, so a loop nested inside a loop over user-sized data is the first place to look.

  • The notation is silent about small n, about which case it describes, and about memory — measure whenever the answer turns on one of those.