B1 · Part 1 · Beginner
Complexity analysis
The one skill every interviewer is silently scoring, whether they say so or not.
19On this page
- What Big-O actually measures
- The formal definition — worth seeing once
- Big-O's two siblings: Ω and Θ
- Best, average, and worst case — three different questions
- Multiple inputs — when there isn't just one "n"
- The picture that makes it click
- Why the shape matters more than it seems — actual numbers
- The complexities you'll actually see
- Reading complexity out of code
- Loops that don't count up by one
- What built-in calls really cost
- Dropping constants and lower-order terms
- Recurrence relations — how you actually derive O(n log n)
- Memoized recursion: states times work per state
- Space complexity — the part people forget
- Amortized complexity — the array.push() case
- The potential method: amortized cost with a bank account
- Output size, and numbers that are values rather than sizes
- The constraints tell you the target complexity
What Big-O actually measures
Big-O is not a speed measurement. It's a description of how the work grows as the input grows. Two functions can both be "O(n)" and one can be 100x slower than the other in real seconds — Big-O doesn't care. It only answers one question: if you double the input, roughly what happens to the work?
The formal definition — worth seeing once
You'll almost never need to write this out in an interview, but knowing
it makes every informal explanation click into place. Formally,
f(n) = O(g(n)) means: there exist positive constants
c and n₀ such that
f(n) ≤ c · g(n) for every n ≥ n₀. In plain
words — past some point, f(n) never grows faster than a constant
multiple of g(n). Big-O is an upper bound on growth, not an exact
count.
This is exactly why constants get dropped: f(n) = 5n is
still O(n), because you can always pick a big enough
c (say, c = 5) to make the inequality true.
Big-O cares about the shape as n → ∞, not the specific
multiplier.
Big-O's two siblings: Ω and Θ
Big-O gets all the attention in interviews, but it's technically only the upper bound. Two related notations describe the other directions — worth being able to name if asked "isn't that also Ω(something)?"
| Notation | Means | Plain English |
|---|---|---|
| O(g(n)) | upper bound | "at worst, this many operations" — never more, could be less |
| Ω(g(n)) | lower bound | "at best, this many operations" — never fewer |
| Θ(g(n)) | tight bound | both O and Ω hold — this is genuinely how it grows, not just a ceiling |
Example: linear search is O(n) (never worse than scanning
everything) and Ω(1) (you might get lucky and find
it first) — so it's not Θ(n) in general, because
best and worst case differ. Merge sort, on the other hand, always does
Θ(n log n) — best, average and worst case are all the same shape, so
people often say "O(n log n)" and "Θ(n log n)" almost interchangeably
for it. In interviews, saying "O" when you technically mean "Θ" is
common and accepted — but knowing the difference exists signals real
understanding.
Best, average, and worst case — three different questions
"What's the complexity of X" is actually an incomplete question — the answer can depend on which input you're worried about.
| Algorithm | Best case | Average case | Worst case |
|---|---|---|---|
| Linear search | O(1) — target is first | O(n) | O(n) — target is last or missing |
| Quicksort | O(n log n) | O(n log n) | O(n²) — already-sorted input, bad pivot |
| Binary search | O(1) — target is the middle | O(log n) | O(log n) |
| Insertion sort | O(n) — already sorted | O(n²) | O(n²) — reverse sorted |
Unless told otherwise, interviewers want the worst case — it's the guarantee that holds no matter what input shows up. But naming the best case too (especially when it differs a lot, like quicksort's) shows you actually understand the algorithm's behavior instead of having memorized one number.
Multiple inputs — when there isn't just one "n"
Plenty of real problems take two different collections, and it's a
common mistake to collapse them into one variable when they shouldn't
be. If a function has an array of size a and a second
array of size b:
// O(a + b) — two SEPARATE passes, not nested
function concat(arr1, arr2) {
const result = [];
for (const x of arr1) result.push(x); // O(a)
for (const x of arr2) result.push(x); // O(b)
return result;
}
// O(a × b) — NESTED, every element of one meets every element of the other
function hasCommonElement(arr1, arr2) {
for (const x of arr1) {
for (const y of arr2) {
if (x === y) return true;
}
}
return false;
}
⚠ "O(n²)" can be the wrong (and misleading) answer
If a candidate calls the first example "O(n²)" because they see two
loops, that's a real mistake — the loops are sequential, not nested, so
it's O(a + b), which simplifies to O(n) only if a and b are actually the
same order of magnitude. Saying "O(a + b)" out loud instead of
collapsing to a single n shows you're reasoning about the actual inputs,
not pattern-matching "two loops = squared."
The picture that makes it click
Every explanation of Big-O eventually points at the same chart. Look at it once, properly, and the notation stops being abstract letters and starts being a shape you recognize on sight.
Say it like this → "Big-O describes the growth
rate of an algorithm's work relative to input size, ignoring constants —
it's a shape, not a stopwatch."
Why the shape matters more than it seems — actual numbers
The chart makes the shape obvious, but the real gut-punch is what these shapes mean at realistic input sizes. This is the table that explains why an interviewer's face changes when your solution is O(n²) on an input that might be a million elements.
| Complexity | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| O(1) | 1 | 1 | 1 |
| O(log n) | ~3 | ~10 | ~20 |
| O(n) | 10 | 1,000 | 1,000,000 |
| O(n log n) | ~33 | ~10,000 | ~20,000,000 |
| O(n²) | 100 | 1,000,000 | 1,000,000,000,000 |
| O(2ⁿ) | 1,024 | more than atoms in the universe | — |
A modern CPU does roughly 10⁸–10⁹ simple operations per second. At n = 1,000,000, an O(n) solution finishes in a blink; an O(n²) solution needs a trillion operations — that's minutes to hours, not milliseconds, on the exact same input. This is the entire reason interviewers care so much about the shape and so little about your variable names.
The complexities you'll actually see
| Name | Notation | Feels like | Example |
|---|---|---|---|
| Constant | O(1) |
same work no matter the input size | array index access, hash map lookup |
| Logarithmic | O(log n) |
work halves each step | binary search |
| Linear | O(n) |
one pass over the input | a single loop, array scan |
| Linearithmic | O(n log n) |
a linear pass, log n times | merge sort, quicksort (average) |
| Quadratic | O(n²) |
a loop inside a loop | comparing every pair, bubble sort |
| Exponential | O(2ⁿ) |
doubles with every extra input | naive recursive Fibonacci, subsets |
| Factorial | O(n!) |
every possible ordering | brute-force permutations |
In interviews, almost every answer you'll ever give is one of these seven. If you can name which shape your solution is and defend why, you've already cleared the bar most candidates trip on.
Reading complexity out of code
The rule of thumb: count the loops, not the lines.
// O(1) — no loop, fixed number of steps
function first(arr) {
return arr[0];
}
// O(n) — one loop over the input
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i];
}
return total;
}
// O(n²) — a loop inside a loop, both sized by n
function hasDuplicatePair(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] === arr[j]) return true;
}
}
return false;
}
// O(log n) — the search space halves every step
function binarySearch(sorted, target) {
let lo = 0, hi = sorted.length - 1;
while (lo <= hi) {
const mid = lo + Math.floor((hi - lo) / 2);
if (sorted[mid] === target) return mid;
if (sorted[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
⚠ The hidden O(n) inside a loop
arr.includes(x), arr.indexOf(x) and
[...set] are each O(n) on their own. Call one of them
inside a loop that already runs n times, and the whole function is
quietly O(n²) — even though you only wrote one visible for.
This exact trap is one of the most common ways candidates lose points
without realizing it.
// looks like O(n) — is actually O(n²)
function hasDuplicate(arr) {
const seen = [];
for (const x of arr) {
if (seen.includes(x)) return true; // O(n) work, n times
seen.push(x);
}
return false;
}
// the fix: swap the array for a Set → O(1) lookup → true O(n)
function hasDuplicateFast(arr) {
const seen = new Set();
for (const x of arr) {
if (seen.has(x)) return true;
seen.add(x);
}
return false;
}
Dry run: hasDuplicate([3, 1, 4, 1]) — counting the hidden work
| x | seen before | seen.includes(x) comparisons | Match? |
|---|---|---|---|
| 3 | [] | 0 | no |
| 1 | [3] | 1 (vs 3) | no |
| 4 | [3, 1] | 2 (vs 3, 1) | no |
| 1 | [3, 1, 4] | 2 (vs 3, then 1 — match) | yes → return true |
0 + 1 + 2 + 2 = 5 comparisons to check an array of only 4 elements — the
cost of each includes call grows with how much of
seen exists so far, not a fixed amount. That growing-per-
iteration cost is the O(n²) signature hiding behind what reads like a
single, innocent for...of loop.
Loops that don't count up by one
"Count the loops" works when every loop runs n times. Plenty of loops
don't: the variable doubles, the inner loop starts where the outer one
is, the condition tests i * i. For all of these the honest
method is one question, asked of each loop in turn: how many times
does the body run, as a function of n? The five shapes below cover
almost everything you will meet. Each function counts its own body, so
the table afterwards is measured, not guessed.
function countDoubling(n) {
let steps = 0;
for (let i = 1; i < n; i *= 2) steps++;
return steps;
}
function countTriangle(n) {
let steps = 0;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) steps++;
}
return steps;
}
function countSqrt(n) {
let steps = 0;
for (let i = 2; i * i <= n; i++) steps++;
return steps;
}
function countHarmonic(n) {
let steps = 0;
for (let i = 1; i <= n; i++) {
for (let j = i; j <= n; j += i) steps++;
}
return steps;
}
function countNestedDoubling(n) {
let steps = 0;
for (let i = 0; i < n; i++) {
for (let j = 1; j < n; j *= 2) steps++;
}
return steps;
}
| n | countDoubling | countTriangle | countSqrt | countHarmonic | countNestedDoubling |
|---|---|---|---|---|---|
| 10 | 4 | 45 | 2 | 27 | 40 |
| 100 | 7 | 4,950 | 9 | 482 | 700 |
| 1,000 | 10 | 499,500 | 30 | 7,069 | 10,000 |
| 10,000 | 14 | 49,995,000 | 99 | 93,668 | 140,000 |
Read the columns against the n that produced them and the shapes appear. Here is where each one comes from.
-
Doubling is logarithmic.
itakes the values 1, 2, 4, 8, ..., 2k−1 and stops once 2k reaches n. So the body runs ⌈log₂ n⌉ times: 10 times for n = 1,000, 20 for a million, and only 30 for a billion. Halving (n = Math.floor(n / 2)until it hits 0) is the same loop run backwards, which is exactly why binary search is O(log n). -
A dependent inner loop is quadratic, with a half in front. When
the outer variable is
i, the inner loop runsn − 1 − itimes, so the total is (n−1) + (n−2) + ... + 1 + 0 = n(n−1)/2. For n = 5 that is 4 + 3 + 2 + 1 + 0 = 10. The ½ is a constant, so the class is still O(n²): halving the work of a quadratic loop does not make it a different shape. -
A condition on
i * iis a square root.i * i <= nis the same test asi <= √n, so the loop runs about √n times. Trial division for primality stops there because if n = a · b with a ≤ b, then a ≤ √n: any factor pair has a member at or below the root. A million costs about a thousand steps. -
A stride that grows with
iis harmonic. The inner loop ofcountHarmonicruns ⌊n/i⌋ times, so the total is n · (1 + ½ + ⅓ + ... + 1/n) ≈ n · ln n. It is not n² because most values ofiare large and their inner loop runs only a few times; for n = 100 the exact count is 482, close to 100 · ln 100 ≈ 460. This is the shape of the sieve of Eratosthenes, though the real sieve only strides from primes, which brings it down to O(n log log n). -
A linear loop around a logarithmic one multiplies.
countNestedDoublingruns n times a loop that runs log n times: O(n log n), and the table shows exactly n × ⌈log₂ n⌉ (1,000 × 10 = 10,000).
One more shape matters a great deal for the next chapters: a loop inside a loop whose inner variable does not reset. This is the engine of every two-pointer and sliding-window solution.
function countWindowMoves(nums, limit) {
let left = 0, sum = 0, moves = 0;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
moves++;
while (sum > limit && left <= right) {
sum -= nums[left];
left++;
moves++;
}
}
return moves;
}
It has a while inside a for, and it is O(n), not
O(n²). left never moves backwards and never passes
right + 1, so across the whole run it advances at
most n times in total. right advances n times. The inner loop
does not restart from zero on every outer step, so the two loops share a
budget of at most 2n moves instead of multiplying. For
countWindowMoves([4, 2, 3, 5, 1], 6) the moves are 5 steps of
right plus 3 of left, 8 in all, well under 2n =
10.
⚠ Nested loops do not always mean a product
Before multiplying two loops, ask whether the inner loop's variable is
reset to the same starting value on every outer step. If it is
(
let j = 0 inside the outer body), multiply the two counts. If
it is shared and only ever moves forward, add them. The mistake runs both
ways: calling a pointer loop O(n²) undersells the solution, and calling a
real double loop O(n) because "the inner one is small" oversells it.
What built-in calls really cost
Most hidden quadratics come from one line that looks like a single step but is a loop inside the engine. The language specification does not promise any of these costs, and an engine is free to be faster than the table, but this is what to assume in an interview and say out loud. Read the right-hand column as "the work this line is allowed to hide".
| Call | Assume | Why |
|---|---|---|
arr[i], arr.length, s[i], s.charCodeAt(i) | O(1) | one index computation |
push, pop | amortized O(1) | the end of the array is the cheap end |
shift, unshift, splice(i, k) | O(n) | elements after the change move over |
slice(a, b) | O(b − a) | it builds a new array, so it copies the piece |
concat, [...arr], Array.from(arr), reverse, join | O(n) | each one touches every element |
includes, indexOf, find, some, every | O(n) | a scan that may stop early, worst case the whole array |
map, filter, forEach, reduce | O(n) × the callback | a callback that itself scans makes the whole call quadratic |
sort(compare) | O(n log n) comparisons | multiply by what one comparison costs |
Math.max(...arr) | O(n) | spreading a very large array into arguments can also throw a RangeError |
s + t, s.slice(a, b), s.repeat(k) | O(length of the result) | strings are immutable, so a new one is built |
s === t | O(length of the shorter) | comparison walks until the first difference |
s.split(""), [...s] | O(n) | one new element per character |
map.get, map.set, set.has, set.add | O(1) average | for a string key add the O(L) cost of hashing a key of length L |
Object.keys, Object.values, Object.entries | O(n) | builds an array of every property |
Two rows deserve a second look. The callback row is where most
accidental quadratics are born:
arr.filter((x) => other.includes(x)) is one line and
O(n × m), because the callback's includes scans
other for every element. And the string-key row is the
polite lie of hash maps: a map of n strings, each of length L, costs
O(n · L) to fill, not O(n), because every key has to be hashed
character by character. When the strings are short that is a constant, and
when the interviewer says "each word can be up to 10,000 characters", it
is not.
Here is the table at work on a short function. Read it line by line, the way you would out loud.
function lengthsReversed(words) { // n words, each up to L characters
const sorted = [...words].sort(); // spread O(n); sort O(n log n) compares, each up to O(L)
const out = [];
for (const w of sorted) { // n iterations
out.unshift(w.length); // O(n) every time: the hidden loop
}
return out;
}
| Line | Cost |
|---|---|
[...words] | O(n) |
.sort() | O(n log n) comparisons × O(L) per comparison = O(n · L · log n) |
out.unshift(...) in a loop of n | n calls × O(n) each = O(n²) |
| Total | O(n · L · log n + n²), with O(n) extra space |
Which term wins depends on the input: with short words the quadratic
unshift loop dominates, and with a few very long words the
sort does. Naming both terms instead of rounding to one is what shows the
interviewer you looked inside the lines. The fix for the loop is to
push and reverse once at the end, which is O(n).
Dropping constants and lower-order terms
O(2n) is written as O(n). O(n² + n)
is written as O(n²). Big-O describes what dominates as
n gets large — the constant factor and the smaller terms
stop mattering. This is also why "my solution does 3 passes instead of
1" is still O(n), just with a bigger constant. It's a
legitimate follow-up question ("can you get it to one pass?") but it
doesn't change the Big-O class.
Recurrence relations — how you actually derive O(n log n)
For recursive code, "count the loops" doesn't work — you need a recurrence relation: an equation describing the work at size n in terms of the work at smaller sizes. Merge sort's recurrence is the classic example:
T(n) = 2·T(n/2) + O(n)
↑ ↑
2 subproblems the merge step, linear work
of half the size
Read it as: "the cost of sorting n elements equals the cost of sorting
two halves, plus the linear-time work to merge them back together."
Solving this (formally, by repeatedly substituting, or informally with
the recursion-tree diagram from the sorting chapter — each of log n
levels does O(n) total work) gives T(n) = O(n log n).
The Master Theorem — the shortcut for "obvious shape" recurrences
For any recurrence of the form
T(n) = a·T(n/b) + O(nᵈ)
(a subproblems, each of size n/b, plus O(nᵈ) work to combine them),
compare d to log_b(a):
- if
d < log_b(a)→T(n) = O(n^(log_b a))— the recursion dominates - if
d = log_b(a)→T(n) = O(nᵈ log n)— balanced (this is merge sort: a=2, b=2, d=1, log₂2=1=d) - if
d > log_b(a)→T(n) = O(nᵈ)— the combine step dominates
| Algorithm | Recurrence | a, b, d | Result |
|---|---|---|---|
| Binary search | T(n) = T(n/2) + O(1) | a=1, b=2, d=0 | O(log n) |
| Merge sort | T(n) = 2T(n/2) + O(n) | a=2, b=2, d=1 | O(n log n) |
| Binary tree traversal | T(n) = 2T(n/2) + O(1) | a=2, b=2, d=0 | O(n) |
| Naive recursive Fibonacci | T(n) = T(n-1) + T(n-2) + O(1) | doesn't fit the form (n-1 and n-2, not n/b) | O(2ⁿ) upper bound (the tight bound is O(φⁿ), φ ≈ 1.618) |
You will not be asked to apply the Master Theorem from memory in most interviews — but being able to write down a recurrence for your own recursive solution, and reason informally about "how many levels × how much work per level," is a real and commonly-tested skill.
Memoized recursion: states times work per state
A recurrence like T(n) = T(n−1) + T(n−2) + O(1) counts
calls, and it is the right tool for plain recursion. Add a cache
and it stops being the right tool, because most of those calls no longer
do anything. The count that matters changes to this:
The memoization rule
Time = (number of distinct states) × (work done in one state, not counting
the recursive calls it makes). Each state's body runs once, because every
later call to it is answered from the cache in O(1). Space = the cache
(one entry per state) plus the deepest chain of calls.
Watch the rule on Fibonacci, counting both the calls and the bodies that actually run.
function fibNaive(n, stats) {
stats.calls++;
if (n < 2) return n;
return fibNaive(n - 1, stats) + fibNaive(n - 2, stats);
}
function fibMemo(n, stats, memo = new Map()) {
stats.calls++;
if (n < 2) return n;
if (memo.has(n)) return memo.get(n);
stats.bodies++;
const value = fibMemo(n - 1, stats, memo) + fibMemo(n - 2, stats, memo);
memo.set(n, value);
return value;
}
| n | fibNaive calls | fibMemo calls | fibMemo bodies run |
|---|---|---|---|
| 6 | 25 | 11 | 5 |
| 10 | 177 | 19 | 9 |
| 20 | 21,891 | 39 | 19 |
| 30 | 2,692,537 | 59 | 29 |
There are n − 1 states that run a body (the values 2 through n), and each body does O(1) work besides its two recursive calls, so the time is O(n). The calls column is about twice the bodies column because every body makes two calls and only the first call into a state does real work. That is the general shape: calls ≤ states × calls per body, so counting bodies loses at most a constant factor.
The rule matters most when the work per state is not constant. Minimum coins to make an amount is the standard example:
function minCoins(coins, amount) {
const memo = new Map();
function solve(left) {
if (left === 0) return 0;
if (left < 0) return Infinity;
if (memo.has(left)) return memo.get(left);
let best = Infinity;
for (const coin of coins) {
best = Math.min(best, solve(left - coin) + 1);
}
memo.set(left, best);
return best;
}
const result = solve(amount);
return result === Infinity ? -1 : result;
}
| Quantity | Value | Reason |
|---|---|---|
| States | amount | left can be 1, 2, ..., amount; 0 and negatives return immediately |
| Work per state | coins.length | one loop over the coin list, plus O(1) cache work |
| Time | O(amount × coins.length) | states × work per state |
| Space | O(amount) | the cache has one entry per state |
With coins = [1, 2, 5] and an amount of 11, 100 or 1,000, the
count of bodies run is exactly 11, 100 and 1,000: one per amount, as the
table says. Without the cache the same recursion branches three ways at
every level and its call tree grows exponentially in the amount.
⚠ Two things the rule does not tell you
The stack is still there. Memoization removes repeated work, not
depth:
minCoins([1], 10000) recurses 10,000 calls deep and
throws a RangeError on a default Node 24 stack, even though it
has only 10,000 states. A bottom-up DP table has
no such limit. The state must be the whole state. If the answer
depends on two parameters but the cache key uses one, the cache returns
answers computed for a different question, and you get wrong results, not
slow ones. The number of states is the product of the ranges of every
parameter in the key.
The same rule prices every DP you will write later: a table of r × c cells with O(1) work per cell is O(r · c); a table of n cells where each looks back over all earlier cells is O(n²). See DP in one dimension for the full treatment.
Space complexity — the part people forget
Space complexity counts extra memory your algorithm uses, not counting the input itself. Two things people forget to count:
-
Output that isn't asked for as input — building a new array to
return costs O(n) space, even if you never call
new Array()explicitly. - The call stack. Recursion isn't free — each call frame sits on the stack until it returns. A recursive function that goes n levels deep costs O(n) space even if it allocates nothing else.
// O(n) time, O(1) space — no extra structure grows with input
function maxValue(arr) {
let max = -Infinity;
for (const x of arr) if (x > max) max = x;
return max;
}
// O(n) time, O(n) space — the call stack holds n frames
function sumRecursive(arr, i = 0) {
if (i === arr.length) return 0;
return arr[i] + sumRecursive(arr, i + 1);
}
Amortized complexity — the array.push() case
array.push() is described as O(1), but that's an
amortized average, not a per-call guarantee. Under the hood a
dynamic array is backed by a fixed-size buffer; most pushes are O(1),
but occasionally the buffer is full and the engine allocates a new,
bigger one and copies everything over — an O(n) operation. Because that
expensive copy happens rarely (typically doubling the capacity each
time), the average cost per push, spread over many calls,
works out to O(1). "Amortized O(1)" means exactly this: not every
single call is cheap, but the total cost over many calls divides out to
a constant per call.
⚠ Amortized ≠ average case — a common mix-up
Average case is about the distribution of possible inputs
(quicksort is fast on average because most inputs don't trigger worst-case
pivots). Amortized is about the distribution of cost across a
sequence of operations on the same structure, regardless of
input — push() is amortized O(1) no matter what values you push, because
the guarantee comes from the doubling strategy, not from luck.
The potential method: amortized cost with a bank account
The aggregate method above works when you can add up the whole sequence at once. Many structures are easier to reason about one operation at a time, and for that you use a potential function Φ: a number computed from the structure's current state that stands for "work we have already paid for but not yet done". The amortized cost of an operation is its actual cost plus the change in potential:
amortized = actual + Φ(after) − Φ(before)
Add this up over a sequence of n operations and the Φ terms cancel from end to end, leaving total actual cost = total amortized cost − Φ(final) + Φ(start). So if Φ starts at 0 and never goes negative, the total actual cost is at most the total amortized cost. If every operation has a constant amortized cost, the whole sequence does too.
For the doubling array, pick Φ = 2 · size − capacity. Then the argument has only two cases:
- A push with room to spare. Actual cost 1. The size grows by one, so Φ rises by 2. Amortized = 1 + 2 = 3.
- A push into a full array (size = capacity = c). Actual cost c + 1: copy the c elements, then write one. Before the push Φ = 2c − c = c. Afterwards the size is c + 1 and the capacity is 2c, so Φ = 2(c + 1) − 2c = 2. Amortized = (c + 1) + (2 − c) = 3. The big copy is paid for by the potential the cheap pushes stored up.
So every push has amortized cost at most 3, and n pushes cost at most 3n in total. The code below replays the argument by running a model of the array and recording both numbers for every push.
function simulatePushes(n) {
let size = 0, capacity = 0, actual = 0;
const amortized = [];
for (let k = 0; k < n; k++) {
const before = 2 * size - capacity;
let cost = 1;
if (size === capacity) {
cost += size;
capacity = Math.max(1, capacity * 2);
}
size++;
actual += cost;
amortized.push(cost + (2 * size - capacity) - before);
}
return { actual, amortized };
}
Dry run: the first six pushes
| push | size, capacity before | actual cost | Φ before → after | amortized |
|---|---|---|---|---|
| 1 | 0, 0 (full) | 1 | 0 → 1 | 2 |
| 2 | 1, 1 (full) | 2 | 1 → 2 | 3 |
| 3 | 2, 2 (full) | 3 | 2 → 2 | 3 |
| 4 | 3, 4 | 1 | 2 → 4 | 3 |
| 5 | 4, 4 (full) | 5 | 4 → 2 | 3 |
| 6 | 5, 8 | 1 | 2 → 4 | 3 |
Total actual cost of the first five pushes is 1 + 2 + 3 + 1 + 5 = 12, against a budget of 3 × 5 = 15. The expensive pushes (3 and 5) are exactly the ones where Φ drops. For n = 1,000 the simulation gives a total actual cost of 2,023 and a largest amortized value of 3. The first push shows 2 rather than 3 only because the model starts from capacity 0.
⚠ The potential has two jobs, and both can be forgotten
Φ must start at 0 (or at its minimum) and must never go negative. With
Φ = 2 · size − capacity, an array that starts with capacity 1 and size 0
would begin at Φ = −1 and the "total actual ≤ total amortized" step would
quietly fail. Also keep the idea separate from average case: Φ is a
proof device about a sequence of operations, not a probability over inputs.
You will almost never write a potential function in an interview. What is worth having is the story it tells: every cheap operation pays a little extra into an account, and an occasional expensive one spends the account. That is how to defend "amortized O(1)" for a dynamic array, for the two-stack queue, and for a monotonic stack where each element is pushed once and popped at most once.
Output size, and numbers that are values rather than sizes
Two cases make "count the loops" give an answer that is right on paper and wrong in practice.
The output is part of the work. No algorithm can run faster than the time it takes to write its answer. A function that returns every subset of n numbers must produce 2n arrays whose lengths add up to n · 2n−1, so it is Θ(n · 2n) however cleverly it is written.
function allSubsets(nums) {
const out = [[]];
for (const x of nums) {
const size = out.length;
for (let i = 0; i < size; i++) out.push([...out[i], x]);
}
return out;
}
Counting the work: the loop over nums runs n times, but the
inner loop doubles each round (1, 2, 4, ..., 2n−1 subsets), and
each [...out[i], x] copies the subset it extends. For n = 10
the result has 1,024 subsets with 5,120 elements in total; for n = 20 it has
1,048,576 subsets and 10,485,760 elements. Say so out loud: "O(n · 2ⁿ), and
that is also the size of the output, so it cannot be beaten". The same
reasoning prices all permutations at O(n · n!) and all pairs at O(n²).
A number can be an input whose value is the loop bound. An algorithm is pseudo-polynomial when its cost is polynomial in the numeric value of an input but exponential in the number of digits it takes to write that input. The subset-sum check below is the classic case:
function canMakeSum(nums, target) {
const reachable = new Uint8Array(target + 1);
reachable[0] = 1;
for (const x of nums) {
for (let s = target; s >= x; s--) {
if (reachable[s - x]) reachable[s] = 1;
}
}
return reachable[target] === 1;
}
| target | digits needed to write it | steps for n numbers | memory |
|---|---|---|---|
| 1,000 | 4 | about n × 1,000 | 1 KB |
| 1,000,000 | 7 | about n × 1,000,000 | 1 MB |
| 1,000,000,000 | 10 | about n × 1,000,000,000 | 1 GB |
The time is O(n · target) and the space is O(target). It is "polynomial"
only if you treat target as the input size. But adding one
digit to the target multiplies the work by ten, so in terms of the
length of the input it is exponential. The honest interview statement is
"O(n · T) time and O(T) space, which is fine because T is at most a few
thousand here". Two others you will meet: trial-division primality
(for (let i = 2; i * i <= n; i++)) is O(√N) in the value
N, about 31,621 steps for N = 1,000,000,007, and the 0/1 knapsack DP is
O(n · W) in the capacity W.
Say it like this → "This is O(n · W), where W is
the numeric capacity, not the input length. It is pseudo-polynomial: fine
for W up to about a million, hopeless if W can be a billion, and in that
case I'd look for a different formulation."
The constraints tell you the target complexity
The most useful complexity skill in an interview happens before any code: read the limits on n and work backwards. A comfortable budget to plan for is about 108 simple steps, which is a rule of thumb for choosing an approach, not a measurement. The table lists the largest growth rate that fits under that budget for each size of n, and the kind of solution that usually lives there.
| Largest n | Fits | Work at that n | Typical approach |
|---|---|---|---|
| about 10 | O(n!) | 10! = 3,628,800 | try every ordering: permutations, brute-force backtracking |
| about 20 to 25 | O(2ⁿ), or O(n · 2ⁿ) near 20 | 2²⁰ = 1,048,576 (times 20 for n · 2ⁿ); 2²⁵ = 33,554,432 | try every subset, bitmask, meet in the middle |
| about 500 | O(n³) | 500³ = 125,000,000 | three nested loops, interval DP, Floyd–Warshall |
| about 5,000 | O(n²) | 5,000² = 25,000,000 | all pairs, 2D DP, two nested loops |
| about 10⁵ | O(n log n) | about 1.7 million | sorting, heaps, binary search per element, balanced trees |
| about 10⁷ | O(n) | about 10⁷ | one pass, hash map, two pointers, prefix sums |
| 10⁹ or more | O(log n) or O(1) | about 30 | binary search on the answer, math formula, fast exponentiation |
Use it in two directions. Forward: when a problem says n ≤ 105 and your first idea is an O(n²) pair loop (1010 steps), you know before writing a line that the idea is too slow and a hash map, sort, or pointer technique is needed. Backward: when the limit is n ≤ 20 and someone proposes a clever O(n) greedy, the small limit is a hint that the intended solution is an exponential search, and the greedy may be wrong. The sizes in the table also tell you what not to over-engineer: at n ≤ 1,000, an O(n²) loop is 106 steps and a perfectly good answer.
⚠ The table is a starting guess, not a proof
Constants matter at the edges: an O(n log n) solution with a heavy
comparator, or an O(n²) one that allocates in its inner loop, can fail
where the table says it fits. Strings multiply everything by their length
(see the string-key row above). And when the statement gives no limits at
all, ask for them: it is the single question that most changes your
answer.
The habit to build
Before you write a line of code in an interview, say the shape out
loud: "I'll scan the array once and use a hash map for lookups, so
this should be O(n) time, O(n) space." State it, then build toward it —
it turns your solution into a plan instead of a guess.
// what's the time complexity of this function? try changing
// the input size in your head before running — does the pattern hold?
function countPairs(arr) {
let count = 0;
for (let i = 0; i < arr.length; i++) {
for (let j = 0; j < arr.length; j++) {
if (arr[i] + arr[j] === 10) count++;
}
}
return count;
}
console.log(countPairs([1, 9, 2, 8, 3, 7]));
Two nested loops, each running the full length of the array → O(n²) time, O(1) space. Notice it doesn't matter that the inner loop "only" checks a sum — the shape is decided by the loop structure, not what's inside it.
Before you move on
- Read the time and space complexity off a function by counting loops and allocations, not lines of code.
- Spot a hidden O(n) call (includes, indexOf, spreading a Set) sitting inside a loop, and explain why that makes the whole function O(n²).
- Explain the difference between O, Ω and Θ, and why interviews almost always mean "worst case" when they say Big-O.
- Derive O(n log n) from a recurrence like T(n) = 2T(n/2) + O(n), at least at the level of "why the log n shows up."
- State a solution's complexity out loud before writing it, as a plan rather than an afterthought.
- Work out how many times a loop body runs when the variable doubles, starts at the outer index, tests
i * i, or strides byi, and tell a pointer loop that shares a budget from a nested loop that multiplies. - Price the built-in calls in a loop (
slice, spread,sort, string keys) instead of treating each as one step. - Cost a memoized recursion as states times work per state, and say why a cache does not shrink the call stack.
- Say why an output of size 2ⁿ forces the time, and what "pseudo-polynomial" means for a loop bounded by a value.
- Turn a limit such as n ≤ 10⁵ into the complexity you are allowed to use before writing any code.
Opens in the editor: write it, run it, and check it against real tests.
All 217 for this level →5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.
Finished reading?
0 of 48 chapters read in this series