../

Big-O notation

How to read, derive and state time and space complexity: the notation, growth rates, rules for simplifying, loops, recursion trees, the master theorem, amortized cost, what built-ins cost in TypeScript, JavaScript and Python, and which complexity an input size demands. The structures themselves are in Arrays, hashing & lists and Trees & graphs; logs are refreshed in Math fundamentals.

What Big-O means

Big-O describes how cost grows with input size nn, ignoring machine speed and constant factors. Formally, f(n)=O(g(n))f(n) = O(g(n)) if some cc and n0n_0 give f(n)≤c⋅g(n)f(n) \le c \cdot g(n) for every n≥n0n \ge n_0.

NotationBoundReads asExample
O(g)O(g)uppergrows no faster than gginsertion sort is O(n2)O(n^2)
Ω(g)\Omega(g)lowergrows at least as fast as ggany comparison sort is Ω(nlog⁡n)\Omega(n \log n) in the worst case
Θ(g)\Theta(g)tightboth O(g)O(g) and Ω(g)\Omega(g)merge sort is Θ(nlog⁡n)\Theta(n \log n)
o(g)o(g), ω(g)\omega(g)strictstrictly slower / fastern=o(n2)n = o(n^2)

In interviews "Big-O" means the tightest upper bound you can justify: say O(n)O(n) for a single pass, not O(n2)O(n^2), even though both are technically true.

CaseMeaningExample: quicksortExample: hash lookup
Bestcheapest input of size nnO(nlog⁡n)O(n \log n)O(1)O(1)
Averageexpected over inputs (or random choices)O(nlog⁡n)O(n \log n)O(1)O(1)
Worstmost expensive inputO(n2)O(n^2), sorted input with a bad pivotO(n)O(n), every key collides
Amortizedworst-case total over a sequence, divided by its lengthdynamic array push: O(1)O(1)

Best, average and worst are which input; OO, Ω\Omega, Θ\Theta are which bound. They are independent: "worst case Θ(n2)\Theta(n^2)" is a valid statement.

Why constants drop: 3n3n and 100n100n both double when nn doubles, and for large enough nn any c⋅nc \cdot n beats any n2n^2. Constants still matter in practice (a cache-friendly array scan beats a pointer-chasing list scan of the same O(n)O(n)), so mention them when comparing two solutions of the same class.

Growth rates

24681012141610203040506070
slow growers O(1) O(log n) O(n) O(n log n)
246810102030405060708090100
fast growers O(n) O(n log n) O(n²) O(2ⁿ)
ClassNamen = 10n = 10³n = 10⁶Typical source
O(1)O(1)constant111index, hash lookup, push
O(log⁡n)O(\log n)logarithmic31020binary search, balanced tree, heap op
O(n)O(n)linear1010³10⁶one pass, two pointers
O(nlog⁡n)O(n \log n)linearithmic3310⁴2 × 10⁷sorting, divide and conquer
O(n2)O(n^2)quadratic10010⁶10¹²all pairs, nested loops
O(n3)O(n^3)cubic10³10⁹10¹⁸triple loops, Floyd–Warshall
O(2n)O(2^n)exponential1 024≈ 10³⁰¹neverall subsets
O(n!)O(n!)factorial3.6 × 10⁶neverneverall permutations

Logs grow so slowly that log⁡2n≤64\log_2 n \le 64 for any nn that fits in memory. The base of a log never matters in Big-O: log⁡an=log⁡bn/log⁡ba\log_a n = \log_b n / \log_b a, a constant factor.

Simplification rules

RuleExampleResult
Drop constant factors5n+35n + 3O(n)O(n)
Drop lower-order termsn2+100n+log⁡nn^2 + 100n + \log nO(n2)O(n^2)
Sequential steps addsort, then one passO(nlog⁡n+n)=O(nlog⁡n)O(n \log n + n) = O(n \log n)
Nested steps multiplya loop of nn doing O(log⁡n)O(\log n) workO(nlog⁡n)O(n \log n)
Different inputs, different variablesloop over a, then over bO(a+b)O(a + b), not O(n)O(n)
Nested over different inputsfor each of a, scan bO(a⋅b)O(a \cdot b)
Log base is irrelevantlog⁡2n\log_2 n, log⁡10n\log_{10} nO(log⁡n)O(\log n)
Exponent base is relevant2n2^n vs 3n3^ndifferent classes
Logs of polynomialslog⁡(n3)=3log⁡n\log(n^3) = 3 \log nO(log⁡n)O(\log n)
Stirlinglog⁡(n!)\log(n!)Θ(nlog⁡n)\Theta(n \log n)
Output size countsreturn all n2n^2 pairsat least O(n2)O(n^2)
String length countshash or compare strings of length LLO(L)O(L) each, not O(1)O(1)

Name every variable you use: "O(n⋅L)O(n \cdot L) where nn is the word count and LL the longest word", "O(V+E)O(V + E) for a graph", "O(r⋅c)O(r \cdot c) for a grid".

Analyzing loops

Count how many times the innermost statement runs, as a function of the input.

Loop shapeIterationsCost
i from 0 to nnnO(n)O(n)
i from 0 to n, j from i + 1 to nn(n−1)/2n(n-1)/2O(n2)O(n^2)
i *= 2 or n /= 2 each steplog⁡2n\log_2 nO(log⁡n)O(\log n)
i to n, inner j *= 2 to nnlog⁡nn \log nO(nlog⁡n)O(n \log n)
while i * i ≤ nn\sqrt{n}O(n)O(\sqrt{n})
i to n, inner j += i to nn/1+n/2+⋯≈nln⁡nn/1 + n/2 + \dots \approx n \ln nO(nlog⁡n)O(n \log n) (harmonic series)
two loops one after the othern+mn + mO(n+m)O(n + m)
two pointers moving toward each otherat most nn moves in totalO(n)O(n)
sliding window: right moves nn times, left at most nn2n2nO(n)O(n), though it looks nested

// O(n): one pass
function total(xs: number[]): number {
  let s = 0;
  for (const x of xs) s += x;
  return s;
}
 
// O(n²): every pair, n(n−1)/2 iterations
function pairsSumTo(xs: number[], t: number): number {
  let count = 0;
  for (let i = 0; i < xs.length; i++)
    for (let j = i + 1; j < xs.length; j++)
      if (xs[i] + xs[j] === t) count++;
  return count;
}
 
// O(log n): n halves every step
function halvings(n: number): number {
  let steps = 0;
  for (; n > 1; n = Math.floor(n / 2)) steps++;
  return steps;
}
 
// O(n log n): n outer steps × log n inner steps
function nLogN(n: number): number {
  let ops = 0;
  for (let i = 0; i < n; i++)
    for (let j = 1; j < n; j *= 2) ops++;
  return ops;
}
 
// O(a · b): two inputs, two variables
function common(xs: string[], ys: string[]): number {
  let hits = 0;
  for (const x of xs)
    for (const y of ys) if (x === y) hits++;
  return hits;
}

Recursion trees

Cost of a recursive function = sum over every call of the work done in that call. Draw the tree: branching factor bb (calls per call), depth dd, work per node. With O(1)O(1) work per node the total is the node count, about bdb^d.

 merge sort: T(n) = 2T(n/2) + O(n)
 
 level        calls   size each   work per level
   0             1        n             n
   1             2       n/2            n
   2             4       n/4            n
   …             …        …             …
 log₂ n          n        1             n
                                  total n · log n
 
 naive fib: T(n) = T(n−1) + T(n−2) + O(1)
 branching 2, depth n  →  at most 2ⁿ calls (really ≈ 1.618ⁿ)

// O(2ⁿ) time, O(n) stack: two calls, depth n
function fib(n: number): number {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
 
// O(n) time and space: each n computed once
const memo = new Map<number, number>();
function fibMemo(n: number): number {
  if (n < 2) return n;
  const hit = memo.get(n);
  if (hit !== undefined) return hit;
  const v = fibMemo(n - 1) + fibMemo(n - 2);
  memo.set(n, v);
  return v;
}
 
// O(log n): T(n) = T(n/2) + O(1)
function power(x: number, n: number): number {
  if (n === 0) return 1;
  const half = power(x, Math.floor(n / 2));
  return n % 2 === 0 ? half * half : half * half * x;
}

Memoization turns "calls in the tree" into "distinct arguments × work per argument": that is the whole idea behind dynamic programming.

Master theorem

For divide and conquer, T(n)=a T(n/b)+O(nd)T(n) = a\,T(n/b) + O(n^d) with a≥1a \ge 1 subproblems of size n/bn/b and O(nd)O(n^d) work to split and combine. Compare dd with log⁡ba\log_b a:

CaseConditionResultWhere the work is
1d<log⁡bad < \log_b aO(nlog⁡ba)O(n^{\log_b a})the leaves (many small calls)
2d=log⁡bad = \log_b aO(ndlog⁡n)O(n^d \log n)spread evenly over log⁡n\log n levels
3d>log⁡bad > \log_b aO(nd)O(n^d)the root (the combine step)
RecurrenceResultAlgorithm
T(n)=T(n/2)+O(1)T(n) = T(n/2) + O(1)O(log⁡n)O(\log n)binary search
T(n)=T(n/2)+O(n)T(n) = T(n/2) + O(n)O(n)O(n)quickselect (average), halving work
T(n)=2T(n/2)+O(1)T(n) = 2T(n/2) + O(1)O(n)O(n)tree traversal, recursive max
T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n)O(nlog⁡n)O(n \log n)merge sort, quicksort (average)
T(n)=2T(n/2)+O(nlog⁡n)T(n) = 2T(n/2) + O(n \log n)O(nlog⁡2n)O(n \log^2 n)naive closest-pair
T(n)=3T(n/2)+O(n)T(n) = 3T(n/2) + O(n)O(n1.585)O(n^{1.585})Karatsuba multiplication
T(n)=4T(n/2)+O(n)T(n) = 4T(n/2) + O(n)O(n2)O(n^2)naive divide-and-conquer multiply
T(n)=7T(n/2)+O(n2)T(n) = 7T(n/2) + O(n^2)O(n2.81)O(n^{2.81})Strassen matrix multiply
T(n)=T(n−1)+O(1)T(n) = T(n-1) + O(1)O(n)O(n)linear recursion (not master form)
T(n)=T(n−1)+O(n)T(n) = T(n-1) + O(n)O(n2)O(n^2)selection sort, quicksort worst case
T(n)=2T(n−1)+O(1)T(n) = 2T(n-1) + O(1)O(2n)O(2^n)Towers of Hanoi, naive subsets
T(n)=n⋅T(n−1)T(n) = n \cdot T(n-1)O(n!)O(n!)all permutations

The nlog⁡2nn \log^2 n row is outside the simple three cases (it is the extended case 2). Subtract-and-conquer recurrences (n−1n - 1) don't fit the theorem: unroll them or draw the tree.

Amortized analysis

Amortized cost = total cost of mm operations ÷ mm, in the worst case over the sequence. No randomness involved (unlike average case). A dynamic array that doubles when full copies 1+2+4+⋯+n/2<n1 + 2 + 4 + \dots + n/2 < n elements over nn pushes, so each push is O(1)O(1) amortized even though one push costs O(n)O(n).

class DynArray {
  private buf = new Float64Array(1);
  private n = 0;
  copies = 0; // total element moves, for analysis
 
  push(x: number): void {
    if (this.n === this.buf.length) {
      const next = new Float64Array(this.n * 2);
      for (let i = 0; i < this.n; i++) next[i] = this.buf[i];
      this.copies += this.n;
      this.buf = next;
    }
    this.buf[this.n++] = x;
  }
 
  get length(): number {
    return this.n;
  }
}
// n pushes: copies = 1 + 2 + 4 + … < 2n → O(1) each
Growth policyAmortized pushNotes
× 2O(1)O(1)up to half the buffer unused
× 1.5 or × 1.125O(1)O(1)any factor above 1 works; smaller wastes less memory, copies more often
+ k (constant)O(n)O(n)total O(n2)O(n^2): the classic mistake

Engines are implementation details, not spec: V8 grows arrays by about 1.5×, CPython lists by about 1.125×. Other amortized-O(1)O(1) patterns: hash table resizing, a queue made of two stacks, a monotonic stack (each element pushed and popped once), a sliding window (each index enters and leaves once). Union-find with both optimizations is O(α(n))O(\alpha(n)) amortized, effectively constant (see union-find).

Space complexity

Usually quoted as auxiliary space: memory beyond the input (and often beyond the output). Say which one you mean.

Source of memoryCost
A few variables or pointersO(1)O(1)
Hash map / set of seen itemsO(n)O(n)
Copy: xs.slice(), [...xs], xs[a:b], list(xs)O(k)O(k) for kk copied items
Recursion of depth ddO(d)O(d) call stack, even with no other data
Balanced recursion (merge sort, balanced tree)O(log⁡n)O(\log n) stack
Skewed recursion (linked list, degenerate tree)O(n)O(n) stack
Memo table over kk statesO(k)O(k)
BFS queueO(widest level)O(\text{widest level}), up to O(V)O(V)
2-D DP tableO(r⋅c)O(r \cdot c), often reducible to one row O(c)O(c)
Generator / iterator instead of a listO(1)O(1) instead of O(n)O(n)

// O(n) time, O(n) stack: one frame per element
function sumRec(xs: number[], i = 0): number {
  return i === xs.length ? 0 : xs[i] + sumRec(xs, i + 1);
}
 
// O(n) time, O(1) extra space
function sumIter(xs: number[]): number {
  let s = 0;
  for (const x of xs) s += x;
  return s;
}

Stack limits are real: CPython stops at sys.getrecursionlimit() (1 000 by default) with RecursionError; V8 throws RangeError somewhere around 10⁴ frames (JavaScriptCore allows more; frame-size dependent). CPython and V8 never eliminate tail calls (only JavaScriptCore, in Safari and Bun, does), so portable recursion deeper than a few thousand levels must become a loop with an explicit stack.

Built-ins: TypeScript / JavaScript

MDN and the spec rarely state complexity. The costs below are what V8, SpiderMonkey and JavaScriptCore typically deliver; "spec" marks what is actually guaranteed.

OperationTypical costNotes
xs[i], xs[i] = v, xs.lengthO(1)O(1)dense arrays are contiguous; holes or mixed types may fall back to slower storage
push, popO(1)O(1) amortizedthe end of the array
shift, unshiftO(n)O(n)re-index every element; some engines optimize small cases, don't rely on it
splice(i, k, ...items)O(n)O(n)moves everything after i
includes, indexOf, find, findIndexO(n)O(n)linear scan
slice(a, b), [...xs], concatO(b−a)O(b - a), O(n)O(n), O(n+m)O(n + m)new array (shallow copy)
map, filter, reduce, forEachO(n)O(n) × cost of the callback
reverse, fillO(n)O(n)in place
sort, toSortedO(nlog⁡n)O(n \log n)spec: stable since ES2019; complexity unspecified (V8 uses TimSort); default compares as strings
Array.from, Object.keys, Object.entriesO(n)O(n)builds a new array every call
Map / Set: get, set, has, add, deleteO(1)O(1) averagespec: sublinear on average; engines use hash tables
map.size, set.sizeO(1)O(1)
Iterating a Map / SetO(n)O(n)insertion order (spec)
str[i], str.lengthO(1)O(1)UTF-16 code units
a + b, +=O(n+m)O(n + m) worststrings are immutable (spec); V8 defers the copy with ropes, so += in a loop is usually fine but not guaranteed
slice, substringO(k)O(k)new string
includes, indexOf (substring)O(n⋅m)O(n \cdot m) worst, usually near O(n)O(n)engine-dependent search
split, join, replaceAllO(n)O(n)
JSON.stringify, structuredCloneO(size)O(\text{size})deep walks

Built-ins: Python

From the Python wiki TimeComplexity page (CPython); kk is the size of the argument or slice.

OperationAverageWorst / notes
list: xs[i], xs[i] = v, len(xs)O(1)O(1)
list: append, pop()O(1)O(1)amortized
list: pop(0), insert(0, x), del xs[i]O(n)O(n)shifts the tail
list: x in xs, index, count, remove, min, maxO(n)O(n)linear scan
list: xs[a:b]O(k)O(k)copies; xs[:] is O(n)O(n)
list: extend, +=O(k)O(k)xs + ys builds a new O(n+k)O(n + k) list
list: sort, sortedO(nlog⁡n)O(n \log n)stable (Timsort family)
dict: d[k], d[k] = v, k in d, del d[k]O(1)O(1)O(n)O(n) worst with collisions
dict: iteration, copyO(n)O(n)insertion order (3.7+)
set: x in s, add, removeO(1)O(1)O(n)O(n) worst
set: s | tO(len(s)+len(t))O(len(s) + len(t))
set: s & tO(min⁡(len(s),len(t)))O(\min(len(s), len(t)))
set: s - tO(len(s))O(len(s))
deque: append, appendleft, pop, popleftO(1)O(1)
deque: d[i]O(1)O(1) at the endsO(n)O(n) in the middle (docs)
deque: rotate(k), extendO(k)O(k)
heapq: heappush, heappop, heapreplaceO(log⁡n)O(\log n)
heapq: heapifyO(n)O(n)linear, not nlog⁡nn \log n
heapq: nlargest(k, xs), nsmallestO(nlog⁡k)O(n \log k)
bisect: bisect_leftO(log⁡n)O(\log n)insort is O(n)O(n) because of the insert
str: s[i], len(s)O(1)O(1)
str: s + t, s[a:b]O(n+m)O(n + m), O(k)O(k)immutable: always a new string
str: "".join(parts)O(total length)O(\text{total length})the right way to build strings
str: sub in s, find, replace, splitO(n)O(n) typical
sum, any, all, zip, enumerateO(n)O(n)zip/enumerate are lazy

Hashing a str or tuple key costs O(L)O(L) in its length (strings cache their hash after the first time).

Input size → target complexity

Budget: roughly 10⁸ simple operations per second for compiled or JIT-compiled code (C++, Java, JS), about 10⁷ for CPython, and judges usually allow 1–2 seconds.

Max nTargetTypical technique
≤ 10O(n!)O(n!)permutations, brute force
≤ 20O(2n)O(2^n), O(2n⋅n)O(2^n \cdot n)subsets, bitmask DP, backtracking
≤ 100O(n4)O(n^4)several nested loops
≤ 500O(n3)O(n^3)interval DP, Floyd–Warshall
≤ 5 000O(n2)O(n^2)2-D DP, all pairs
≤ 10⁵ – 10⁶O(nlog⁡n)O(n \log n)sorting, heaps, binary search, segment trees
≤ 10⁸O(n)O(n)one pass, hashing, two pointers, prefix sums
above 10⁸O(log⁡n)O(\log n) or O(1)O(1)math, binary search on the answer

In interviews the constraint is often unstated: ask for it, or assume the brute force is too slow and aim one class better.

Common mistakes

Looks likeReally isFix
if x in some_list inside a loopO(n⋅m)O(n \cdot m)build a set once
xs.includes(x) in a filterO(n⋅m)O(n \cdot m)new Set(xs).has(x)
queue.shift() / queue.pop(0) in BFSO(n2)O(n^2) totalhead index, ring buffer, collections.deque
s += ch in a Python loopup to O(n2)O(n^2)append to a list, then "".join
[...acc, x] or { ...acc, [k]: v } in reduceO(n2)O(n^2)push / assign in a plain loop
f(xs[1:]) / f(xs.slice(1)) in recursionO(n2)O(n^2) time and spacepass an index
sorted() / .sort() inside a loopO(n⋅nlog⁡n)O(n \cdot n \log n)sort once, or use a heap
Object.keys(o).length in a loopO(n)O(n) per callkeep a counter or use Map.size
min(xs) / Math.max(...xs) every iterationO(n)O(n) per calltrack the running min, or a heap
hashing long strings or tuples as keysO(L)O(L) per lookupcount LL in the bound
str.split / list(s) to "just look"O(n)O(n) copyindex directly
forgetting the outputprinting all subsets is O(2n⋅n)O(2^n \cdot n)include output size

// Slow: includes() scans bad for every x: O(n·m)
function keepSlow(xs: string[], bad: string[]): string[] {
  return xs.filter((x) => !bad.includes(x));
}
// Fast: one Set, O(1) average lookups: O(n + m)
function keepFast(xs: string[], bad: string[]): string[] {
  const ban = new Set(bad);
  return xs.filter((x) => !ban.has(x));
}
 
// Slow: shift() moves every element: O(n²) total
function drainSlow(q: number[]): number {
  let s = 0;
  while (q.length > 0) s += q.shift()!;
  return s;
}
// Fast: advance a head index: O(n)
function drainFast(q: number[]): number {
  let s = 0;
  for (let h = 0; h < q.length; h++) s += q[h];
  return s;
}
 
// Slow: the spread copies the accumulator: O(n²)
const squaresSlow = (xs: number[]): number[] =>
  xs.reduce<number[]>((acc, x) => [...acc, x * x], []);
// Fast: push into one array: O(n)
const squaresFast = (xs: number[]): number[] => {
  const out: number[] = [];
  for (const x of xs) out.push(x * x);
  return out;
};

Recipes

Spot the complexity

Use on any solution before stating its cost.

  1. Name the inputs and their sizes (nn, mm, VV, EE, LL).
  2. Find the loops: nested over the same input multiplies, sequential adds.
  3. Look inside each loop for hidden scans: in list, includes, indexOf, slice, shift, pop(0), sorted, min, string +, spread.
  4. For recursion: branching factor, depth, work per call; memoized = states × work per state.
  5. Check for amortized patterns (each element pushed or popped once) before calling a nested loop O(n2)O(n^2).
  6. Add the sort if there is one: O(nlog⁡n)O(n \log n) usually dominates a linear pass.
  7. Space: extra structures + recursion depth + copies; say whether the output counts.

Estimate if it will pass

Use before coding, from the constraints.

  1. Plug the maximum nn into your complexity: n=105n = 10^5 with O(n2)O(n^2) is 101010^{10} operations.
  2. Compare with the budget: about 10810^8 per second (JS, Java, C++), 10710^7 (Python).
  3. Within 10× of the budget: fine. 10–100× over: optimize constants or pick a better class. More: pick a better class.
  4. Use the input-size table backwards: the constraint often tells you which algorithm is expected.

Measure growth by doubling

Use when you can't tell from the code: time f(n)f(n) and f(2n)f(2n). A ratio near 2 is linear, near 2.1–2.2 is nlog⁡nn \log n, near 4 is quadratic, near 8 cubic.

type Fn = (n: number) => unknown;
 
function timeIt(f: Fn, n: number): number {
  const t0 = performance.now();
  f(n);
  return performance.now() - t0;
}
 
function growth(f: Fn, n = 1 << 10): number[] {
  const ratios: number[] = [];
  for (let k = 0; k < 4; k++, n *= 2)
    ratios.push(timeIt(f, 2 * n) / timeIt(f, n));
  return ratios; // ≈2 linear, ≈4 quadratic
}

Warm up JIT engines first (run once and discard) and use sizes large enough for runs of 10 ms or more.

State it in an interview

Use when you finish a solution: one sentence each for time and space, with the dominant step named.

"Time is O(n log n): sorting dominates; the scan after it
 is O(n). Space is O(n) for the hash map; the recursion is
 only O(log n) deep. n is the number of intervals."

Then offer the trade-off: "with a heap this becomes O(nlog⁡k)O(n \log k) time and O(k)O(k) space".

Prove an amortized bound

Use when a nested loop looks quadratic but isn't (monotonic stack, sliding window, two pointers).

  1. Pick one element and follow it: how many times can it be pushed, popped, entered, left?
  2. If each element is touched a constant number of times, the total is O(n)O(n), whatever the loop nesting.
  3. Or charge each expensive operation to the cheap ones that caused it (a doubling copy is paid for by the pushes since the previous copy).

References