DSABig O Notation

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.

Note
Big O describes an **upper bound** — the worst-case ceiling on growth. It does not say anything about the best case or exact running time on specific inputs. We use Ω (Omega) for lower bounds and Θ (Theta) for tight bounds.
The Two Golden Rules: Drop Constants and Lower-Order Terms

When deriving a Big O expression, apply these two simplifications:

  1. 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.

  2. Drop lower-order terms — O(n² + n + 42) becomes O(n²). When n is large, the dominant term swamps all others.

Simplification examples

JS
// 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

JS
// 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
}
Tip
O(1) does not mean "fast". An O(1) operation could take 10 seconds, while an O(n) loop over 5 elements takes microseconds. O(1) means the time does not *grow* with n.
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)

JS
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!
Note
"log n" in algorithm analysis means log base 2 (binary logarithm) by convention, since we typically halve the problem. But O(log₂ n) and O(log₁₀ n) differ only by a constant factor, so the base does not matter for Big O classification.
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

JS
// 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)

JS
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

JS
// 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²)
}
Warning
Nested loops do not automatically mean O(n²). If the inner loop always runs a constant number of times (e.g., always 3 iterations), the total is still O(n). Ask: does the inner loop count depend on 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

JS
// 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

JS
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 permutations
Practical 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
  1. Identify n — usually array length, string length, number of nodes, or value of a number.

  2. Find the dominant loop or recursion — the deepest nested loop or the most-called recursive branch.

  3. Single loop over n → O(n). Nested loops over n → O(n²). Loop that halves each time → O(log n).

  4. Recursive calls: write the recurrence (e.g. T(n) = 2T(n/2) + O(n)) and apply the Master Theorem.

  5. Sum independent sections — keep only the dominant term: O(n) + O(n²) = O(n²).

  6. 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

JS
// 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 improvement
Common Interview Mistakes
  • Forgetting built-in costsarr.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()

Tip
When in doubt in an interview, **state your complexity analysis out loud** before writing code. Interviewers want to see that you think about efficiency *before* optimizing. Say: "A brute force approach is O(n²) but we can use a hash map to reduce it to O(n). Let me write the optimal version."