Big O Notation
Big O notation is the universal language of algorithmic efficiency. It gives you a precise, machine-independent way to describe how an algorithm scales as its input grows toward infinity. Every software engineer needs to speak this language fluently — interviewers ask about it, code reviews depend on it, and production systems live or die by it.
Why We Need Big O
Suppose you write a search function that scans 1,000 items in 1 ms. Will it scan 1,000,000 items in 1,000 ms — or in 1,000,000 ms? The difference is the difference between O(n) and O(n²), and it is the difference between a product that ships and one that falls over under real load. Big O answers: as n grows without bound, what happens to the running time? It deliberately ignores hardware speed, constant factors, and lower-order terms, leaving a clean mental model that is hardware-agnostic and timeless.
Formal Definition
We say f(n) = O(g(n)) if there exist positive constants c and n₀ such that:
f(n) ≤ c · g(n) for all n ≥ n₀
In plain English: beyond some input size n₀, the function f(n) is always bounded above by some constant multiple of g(n). The function g(n) is our growth-rate class — the Big O bound.
The Two Golden Rules: Drop Constants and Lower-Order Terms
When deriving a Big O expression, apply these two simplifications:
-
Drop constant multipliers — O(3n) becomes O(n), O(100n²) becomes O(n²). Constants depend on hardware and implementation details; they do not change the asymptotic growth class.
-
Drop lower-order terms — O(n² + n + 42) becomes O(n²). When n is large, the dominant term swamps all others.
Simplification examples
// This loop runs 3n + 7 operations
function example(arr) {
let count = 0
for (let i = 0; i < arr.length; i++) { // n iterations
count += arr[i] // 1 op
count *= 2 // 1 op
count -= 1 // 1 op
}
// 7 constant-time operations after the loop
return count
}
// Running time: 3n + 7
// After dropping constants & lower-order terms: O(n)
// Two separate sections — pick the dominant term
function twoSections(arr, matrix) {
let sum = 0
for (const x of arr) sum += x // O(n)
for (const row of matrix) // O(n²) — n×n matrix
for (const cell of row)
sum += cell
return sum
// Total: O(n) + O(n²) = O(n²)
}Growth Rate Classes — Slowest to Fastest
The common complexity classes you will encounter, ordered from best to worst:
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)
Notation | Name | n = 10 | n = 100 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|---|---|
O(1) | Constant | 1 | 1 | 1 | 1 |
O(log n) | Logarithmic | 3 | 7 | 10 | 20 |
O(n) | Linear | 10 | 100 | 1,000 | 1,000,000 |
O(n log n) | Linearithmic | 33 | 664 | 9,966 | 19,931,568 |
O(n²) | Quadratic | 100 | 10,000 | 1,000,000 | 10¹² |
O(2ⁿ) | Exponential | 1,024 | ~10³⁰ | infeasible | infeasible |
O(n!) | Factorial | 3,628,800 | incomprehensible | infeasible | infeasible |
O(1) — Constant Time
The algorithm takes the same amount of time regardless of input size. Array index access, hash map lookup, stack push/pop — all O(1). Even 1,000 lines of fixed-cost operations is still O(1).
O(1) examples
// Array element access — always one operation
function getFirst(arr) {
return arr[0] // O(1) regardless of arr.length
}
// Hash map lookup — O(1) average case
function isPresent(map, key) {
return map.has(key)
}
// Even many O(1) operations is still O(1)
function complexButConstant(n) {
let a = n * 2 // O(1)
let b = a + 100 // O(1)
let c = b % 7 // O(1)
return c
// Total: O(4) → O(1) after dropping constants
}O(log n) — Logarithmic Time
The algorithm halves the problem at each step (or thirds, or any constant divisor). Binary search is the canonical example. When n doubles, the running time increases by only 1 unit — this is why O(log n) algorithms handle billions of items with ease.
Binary search — O(log n)
function binarySearch(sortedArr, target) {
let left = 0
let right = sortedArr.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
if (sortedArr[mid] === target) return mid
else if (sortedArr[mid] < target) left = mid + 1 // discard left half
else right = mid - 1 // discard right half
}
return -1
}
// After each iteration, search space is halved:
// n=1000 → 500 → 250 → 125 → 62 → 31 → 15 → 7 → 3 → 1
// At most log₂(1000) ≈ 10 iterations for ANY sorted 1000-element array!O(n) — Linear Time
Time grows proportionally to input size. A single loop over n elements is the classic example. If n doubles, the time doubles. Two separate O(n) loops give O(2n) = O(n), not O(n²).
O(n) — linear scan
// Finding the maximum — must check every element
function findMax(arr) {
let max = -Infinity
for (const val of arr) { // n iterations
if (val > max) max = val // O(1) work per step
}
return max
}
// Two separate loops → O(2n) = O(n)
function sumAndProduct(arr) {
let sum = 0
for (const x of arr) sum += x // first O(n) pass
let product = 1
for (const x of arr) product *= x // second O(n) pass
return { sum, product }
// Total: O(2n) = O(n) ← NOT O(n²)
}O(n log n) — Linearithmic Time
This class appears in efficient sorting algorithms — merge sort, heap sort, and quicksort (average case). Intuition: perform O(log n) passes over the data, each pass doing O(n) work.
Merge sort — O(n log 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 step
}
// Recurrence: T(n) = 2·T(n/2) + O(n)
// Master Theorem → T(n) = O(n log n)
//
// Recursion tree visualization:
// Level 0: 1 subproblem of size n → n work
// Level 1: 2 subproblems of size n/2 → n work total
// Level 2: 4 subproblems of size n/4 → n work total
// ...
// Level log₂n: n subproblems of size 1 → n work total
// Total: n × log₂n levels = O(n log n)O(n²) — Quadratic Time
Typically caused by nested loops where both iterate over n elements. When n = 10,000, O(n²) means 100,000,000 operations — often too slow for interactive use. Always look for a way to reduce nested loops with hash maps or sorting.
O(n²) — nested loops
// Bubble sort — O(n²)
function bubbleSort(arr) {
for (let i = 0; i < arr.length; i++) { // n iterations
for (let j = 0; j < arr.length - 1; j++) { // n iterations
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
}
}
}
return arr
}
// Total: n × n = n² comparisons → O(n²)
// All pairs — still O(n²) even with j = i + 1
function allPairs(arr) {
const pairs = []
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
pairs.push([arr[i], arr[j]])
}
}
return pairs
// Iterations: n(n-1)/2 ≈ n²/2 → O(n²)
}O(2ⁿ) — Exponential Time
Each additional element doubles the work. Exponential algorithms are only practical for very small inputs (roughly n ≤ 20–30). They appear in brute-force solutions — naive recursive Fibonacci, generating all subsets, or some NP-complete problems.
O(2ⁿ) — naive Fibonacci and all subsets
// Naive Fibonacci — exponential due to recomputation
function fibSlow(n) {
if (n <= 1) return n
return fibSlow(n - 1) + fibSlow(n - 2)
// Each call spawns 2 more → 2ⁿ total calls
}
// Generating all subsets — O(2ⁿ) unavoidable (there ARE 2ⁿ subsets)
function allSubsets(arr) {
if (arr.length === 0) return [[]]
const [first, ...rest] = arr
const subsetsWithout = allSubsets(rest)
const subsetsWith = subsetsWithout.map(s => [first, ...s])
return [...subsetsWithout, ...subsetsWith]
// n=3 → 8 subsets, n=20 → 1,048,576, n=40 → over 1 trillion
}
// Fix Fibonacci with memoization → O(n)
const memo = new Map()
function fibFast(n) {
if (n <= 1) return n
if (memo.has(n)) return memo.get(n)
const result = fibFast(n - 1) + fibFast(n - 2)
memo.set(n, result)
return result
}O(n!) — Factorial Time
The slowest common complexity class. Appears in permutation generation and brute-force Travelling Salesman. At n = 20, that is 2,432,902,008,176,640,000 operations — completely infeasible without pruning or heuristics.
O(n!) — generating all permutations
function permutations(arr) {
if (arr.length <= 1) return [arr]
const result = []
for (let i = 0; i < arr.length; i++) {
const current = arr[i]
const remaining = [...arr.slice(0, i), ...arr.slice(i + 1)]
const perms = permutations(remaining) // recurse on n-1 elements
for (const perm of perms) result.push([current, ...perm])
}
return result
}
// n=3 → 6 permutations
// n=5 → 120 permutations
// n=10 → 3,628,800 permutations
// n=15 → 1,307,674,368,000 permutationsPractical Wall-Clock Comparison
Imagine a computer running 10⁹ operations per second. Here is how long different complexities take for n = 1,000:
Complexity | Operations (n=1,000) | Time at 10⁹ ops/sec |
|---|---|---|
O(1) | 1 | 1 nanosecond |
O(log n) | 10 | 10 nanoseconds |
O(n) | 1,000 | 1 microsecond |
O(n log n) | 9,966 | ~10 microseconds |
O(n²) | 1,000,000 | 1 millisecond |
O(n³) | 1,000,000,000 | 1 second |
O(2ⁿ) | 10³⁰¹ | longer than the age of the universe |
Step-by-Step: How to Analyze Any Algorithm
Identify n — usually array length, string length, number of nodes, or value of a number.
Find the dominant loop or recursion — the deepest nested loop or the most-called recursive branch.
Single loop over n → O(n). Nested loops over n → O(n²). Loop that halves each time → O(log n).
Recursive calls: write the recurrence (e.g. T(n) = 2T(n/2) + O(n)) and apply the Master Theorem.
Sum independent sections — keep only the dominant term: O(n) + O(n²) = O(n²).
Drop constants and lower-order terms — O(5n + n log n) = O(n log n).
Two-Sum: O(n²) vs O(n) — A Classic Trade-off
Two-Sum brute force vs hash map
// Approach 1: Brute force — O(n²) time, O(1) space
function twoSumBrute(nums, target) {
for (let i = 0; i < nums.length; i++) {
for (let j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] === target) return [i, j]
}
}
return []
}
// n=10,000 → ~50,000,000 iterations
// Approach 2: Hash map — O(n) time, O(n) space
function twoSumOptimal(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 []
}
// n=10,000 → ~10,000 iterations
// Trade-off: O(n) extra space purchased O(n) speed improvementCommon Interview Mistakes
Forgetting built-in costs —
arr.slice(),str.split(),Array.from()are all O(n). They look "free" but they are not.Independent loops — O(n) + O(n) = O(n), NOT O(n²). Only nested loops multiply complexity.
Recursion depth vs call count — a tree of depth log n can still make O(n) total calls.
Amortized vs worst-case — dynamic array push is O(1) amortized but O(n) worst-case on resize.
Assuming sorted input — binary search is O(log n) only if the array is already sorted; sorting costs O(n log n) extra.
Quick Reference Cheat Sheet
Pattern | Complexity | Example |
|---|---|---|
Single loop over n items | O(n) | Linear search, array sum |
Two nested loops over n | O(n²) | Bubble sort, all pairs |
Three nested loops over n | O(n³) | Naive matrix multiply |
Halve input each step | O(log n) | Binary search |
Loop + halving each level | O(n log n) | Merge sort |
All subsets of n items | O(2ⁿ) | Power set generation |
All orderings of n items | O(n!) | Permutation generation |
Hash map insert / lookup | O(1) average | Frequency counter |
Comparison-based sort | O(n log n) | Array.sort() |