Complexity Analysis
Imagine you write two programs that both solve the same problem. How do you know which one is better? Timing them on your laptop is unreliable — your machine might be fast, the input might be tiny, or another process might be stealing CPU cycles. We need a machine-independent way to reason about efficiency.
That is exactly what complexity analysis gives us. It lets us predict how an algorithm's resource usage (time or memory) scales as the input grows — without running a single line of code.
Why Efficiency Matters
Consider searching for a name in a phone book with one million entries.
- Algorithm A checks every entry one by one → up to 1,000,000 checks.
- Algorithm B opens the book in the middle and repeatedly halves the search space → at most 20 checks.
On a computer that does 10 million operations per second:
- Algorithm A: ~0.1 seconds
- Algorithm B: ~0.000002 seconds (essentially instant)
Now double the phone book to 2 million entries:
- Algorithm A: ~0.2 seconds (doubles with input)
- Algorithm B: ~0.000002 seconds (barely changes — one extra step)
The shape of the growth curve matters more than raw speed. That shape is what Big-O notation captures.
Input Size n
We always express complexity in terms of n, the size of the input. What counts as n depends on the problem:
- Sorting an array → n = array length
- Traversing a graph → n = number of vertices, m = number of edges
- Processing a string → n = string length
- Multiplying two matrices → n = matrix dimension
The goal is to express the number of elementary operations (comparisons, assignments, arithmetic steps) as a mathematical function of n, then describe its growth using Big-O notation.
Counting Operations
Let's count operations precisely in a simple function:
function sumArray(arr) {
let total = 0; // 1 operation
for (let i = 0; i < arr.length; i++) {
total += arr[i]; // n operations (runs once per element)
}
return total; // 1 operation
}
// Exact count: n + 2 operations
// Big-O: O(n) — we drop the constant 2 because it becomes negligible for large nThe exact count is n + 2, but for large n, the 2 is irrelevant. Big-O notation strips away constants and lower-order terms because we care about the growth rate, not the exact count.
Analyzing Single Loops
// O(n) — loop body runs n times
function printAll(arr) {
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]); // O(1) work per iteration
}
}
// Still O(n) — loop runs n/2 times, but O(n/2) = O(n)
function printEvery(arr) {
for (let i = 0; i < arr.length; i += 2) {
console.log(arr[i]);
}
}
// O(log n) — problem halves each iteration
function logLoop(n) {
for (let i = 1; i < n; i *= 2) {
console.log(i); // runs log₂(n) times total
}
}Analyzing Nested Loops
// O(n²) — outer runs n times, inner runs n times for each outer iteration
function printPairs(arr) {
for (let i = 0; i < arr.length; i++) { // n iterations
for (let j = 0; j < arr.length; j++) { // n iterations
console.log(arr[i], arr[j]); // n * n = n² total
}
}
}
// Still O(n²) — triangular pattern is n*(n-1)/2, but still quadratic
function printUniquePairs(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
console.log(arr[i], arr[j]);
}
}
}
// O(n log n) — outer runs n times, inner runs log n times
function nLogN(arr) {
for (let i = 0; i < arr.length; i++) { // n iterations
for (let j = 1; j < arr.length; j *= 2) { // log n iterations
console.log(arr[i], j);
}
}
}Analyzing Recursive Calls
For recursion, draw the call tree and count total work across all nodes.
// O(n) — n recursive calls, O(1) work each
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
// Call chain: factorial(5) -> factorial(4) -> ... -> factorial(1)
// Depth = n, work per call = O(1), total = O(n)
}
// O(2^n) — binary tree of calls, each node does O(1) work
function fibNaive(n) {
if (n <= 1) return n;
return fibNaive(n - 1) + fibNaive(n - 2);
// Each call spawns 2 more. Tree has ~2^n nodes total.
}
// O(n log n) — recurrence T(n) = 2T(n/2) + O(n)
function mergeSort(arr) {
if (arr.length <= 1) return arr;
const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid)); // T(n/2)
const right = mergeSort(arr.slice(mid)); // T(n/2)
return merge(left, right); // O(n) merge work
// log n levels of recursion, n total work per level = O(n log n)
}Formal Big-O Definition
f(n) = O(g(n)) means: there exist positive constants c and n₀ such that for all n ≥ n₀, f(n) ≤ c · g(n).
In plain English: g(n) is an upper bound on f(n) for sufficiently large inputs. We ignore small inputs and only care about the long-term trend.
Related notations:
- Ω (Big-Omega) — lower bound: f(n) grows at least as fast as g(n)
- Θ (Big-Theta) — tight bound: f(n) grows exactly as fast as g(n) (both O and Ω apply)
Growth Rate Table
Notation | Name | n = 10 | n = 100 | n = 1,000 | Example Algorithm |
|---|---|---|---|---|---|
O(1) | Constant | 1 | 1 | 1 | Hash map lookup |
O(log n) | Logarithmic | 3 | 7 | 10 | Binary search |
O(n) | Linear | 10 | 100 | 1,000 | Linear scan |
O(n log n) | Linearithmic | 33 | 664 | 9,966 | Merge sort |
O(n²) | Quadratic | 100 | 10,000 | 1,000,000 | Bubble sort |
O(n³) | Cubic | 1,000 | 1,000,000 | 10⁹ | Naive matrix multiply |
O(2ⁿ) | Exponential | 1,024 | 10³⁰ | Astronomical | Subset enumeration |
O(n!) | Factorial | 3,628,800 | Astronomical | Impossible | Permutation brute force |
Rules for Combining Complexities
Drop constants: O(3n) simplifies to O(n)
Drop lower-order terms: O(n² + n) simplifies to O(n²)
Sequential blocks ADD: O(n) followed by O(n²) gives O(n²)
Nested blocks MULTIPLY: O(n) inside O(n) gives O(n²)
Different inputs use different variables: two separate arrays a and b give O(a + b), not O(n)
// Sequential: O(n) + O(n²) = O(n²) — dominated by the quadratic term
function sequential(arr) {
for (let x of arr) console.log(x); // O(n)
for (let i = 0; i < arr.length; i++) // O(n²)
for (let j = 0; j < arr.length; j++)
console.log(i, j);
}
// Different inputs: O(a·b), NOT O(n²)
function cartesian(arrA, arrB) {
for (let a of arrA) // |arrA| = a
for (let b of arrB) // |arrB| = b
console.log(a, b); // total work = a * b
}Time vs Space Trade-offs
Time and space complexity often trade against each other. Memoization is the canonical example: you allocate extra memory to avoid recomputing values, dramatically reducing runtime.
// Naive — O(2^n) time, O(n) space (only the call stack)
function fibSlow(n) {
if (n <= 1) return n;
return fibSlow(n - 1) + fibSlow(n - 2);
}
// Memoized — O(n) time, O(n) space (cache stores n results)
function fibFast(n, memo = new Map()) {
if (n <= 1) return n;
if (memo.has(n)) return memo.get(n);
const result = fibFast(n - 1, memo) + fibFast(n - 2, memo);
memo.set(n, result);
return result;
}
// Trade-off: O(n) extra space to cut time from O(2^n) down to O(n)Practice: What Is the Complexity?
// Problem 1 — what is the time complexity?
function mystery(n) {
let count = 0;
for (let i = n; i > 0; i = Math.floor(i / 3)) {
count++;
}
return count;
}
// Answer: O(log₃ n) = O(log n). i divides by 3 each step.
// Problem 2 — what is the time and space complexity?
function twoSum(nums, target) {
const seen = new Map();
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
if (seen.has(complement)) return [seen.get(complement), i];
seen.set(nums[i], i);
}
return [];
}
// Answer: O(n) time (one pass through the array),
// O(n) space (hash map stores up to n entries)
// Problem 3 — what is the time complexity?
function hasDuplicate(arr) {
const seen = new Set();
for (const num of arr) {
if (seen.has(num)) return true;
seen.add(num);
}
return false;
}
// Answer: O(n) time. Hash set lookup and insert are O(1) average.