Time Complexity
Time complexity is the art of counting operations — not seconds on a clock, but abstract steps that scale with input size. Mastering time complexity analysis lets you predict an algorithm's behavior on large inputs long before you run it, choose the right algorithm for the job, and communicate performance trade-offs precisely in code reviews and interviews.
What We Actually Count
We count elementary operations — comparisons, arithmetic, assignments, array accesses, function calls. We assume each takes constant time O(1). The total count, expressed as a function of n (the input size), gives us the time complexity. The goal is not an exact count but the growth rate — how the count changes as n grows. That is why we use Big O notation and drop constants and lower-order terms.
Counting operations precisely
function countOps(arr) {
let total = 0 // 1 assignment
for (let i = 0; i < arr.length; i++) { // n iterations of the loop body
total += arr[i] // 1 read + 1 addition + 1 assignment = 3 ops
total *= 2 // 1 multiply + 1 assignment = 2 ops
}
return total // 1 return
}
// Exact count: 1 + n×5 + 1 = 5n + 2
// Big O: O(n) (drop constant 5 and lower-order term 2)Single Loops
A single loop that iterates from 0 to n is the simplest case: O(n). But the loop structure itself can change the complexity dramatically.
Single loop variations
// Standard O(n) — each iteration: O(1) work
function sumArray(arr) {
let sum = 0
for (let i = 0; i < arr.length; i++) { // n iterations
sum += arr[i]
}
return sum // O(n)
}
// O(n/2) = O(n) — halving the range doesn't change the class
function sumEvenIndices(arr) {
let sum = 0
for (let i = 0; i < arr.length; i += 2) { // n/2 iterations
sum += arr[i]
}
return sum // O(n/2) = O(n)
}
// O(log n) — halving the counter, not the range
function countHalving(n) {
let count = 0
while (n > 1) {
n = Math.floor(n / 2) // halve n each step
count++
}
return count // O(log n)
}
// O(n) — even though it looks like it counts down
function countdown(n) {
for (let i = n; i > 0; i--) { // still n iterations
console.log(i)
} // O(n)
}Nested Loops
When one loop contains another, their complexities multiply. The analysis depends on whether the inner loop count depends on the outer counter or is independent.
Nested loop analysis
// Both loops run n times — O(n × n) = O(n²)
function printAllPairs(arr) {
for (let i = 0; i < arr.length; i++) { // n iterations
for (let j = 0; j < arr.length; j++) { // n iterations each time
console.log(arr[i], arr[j])
}
}
}
// Inner loop shrinks: j from i+1 to n
// Total iterations: n(n-1)/2 ≈ n²/2 — still O(n²)
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])
}
}
}
// Inner loop always runs exactly 3 times — O(3n) = O(n)
function fixedInner(arr) {
for (let i = 0; i < arr.length; i++) { // n iterations
for (let k = 0; k < 3; k++) { // always 3 — constant!
console.log(arr[i], k)
}
}
}
// Triple nested loop — O(n³)
function triplePrint(n) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
for (let k = 0; k < n; k++) {
console.log(i, j, k)
}
}
}
}Independent (Sequential) Loops
When loops appear one after another (not nested), their complexities add. You keep only the dominant term.
Sequential loops — add, then keep dominant term
function sequentialLoops(arr, matrix) {
// Section 1: O(n)
let sum = 0
for (const x of arr) sum += x
// Section 2: O(n) — still just adds to O(n)
let max = -Infinity
for (const x of arr) if (x > max) max = x
// Section 3: O(n²) — matrix is n×n
let total = 0
for (const row of matrix)
for (const cell of row)
total += cell
return { sum, max, total }
// Total: O(n) + O(n) + O(n²) = O(2n + n²) = O(n²)
}
// Another example: O(n) + O(n log n) = O(n log n)
function sortAndScan(arr) {
arr.sort() // O(n log n)
let count = 0
for (const x of arr) if (x > 0) count++ // O(n)
return count
// Total: O(n log n + n) = O(n log n)
}Analyzing Function Calls
When a function calls other functions, you must account for the called function's complexity. A single call to a sort function inside a loop can silently inflate complexity from O(n) to O(n² log n).
Function calls multiply complexity
// Calling sort() inside a loop: O(n) × O(n log n) = O(n² log n)
function sortedMax(matrix) {
const results = []
for (const row of matrix) { // n rows
const sorted = [...row].sort() // O(n log n) per row!
results.push(sorted[sorted.length - 1])
}
return results
// Total: O(n × n log n) = O(n² log n)
}
// Better: find max without sorting — O(n) total
function fastMax(matrix) {
return matrix.map(row => Math.max(...row)) // O(n) per row × n rows = O(n²)
}
// indexOf() is O(n) — calling inside a loop makes it O(n²)!
function hasDuplicatesSlow(arr) {
for (let i = 0; i < arr.length; i++) {
if (arr.indexOf(arr[i]) !== i) return true // indexOf is O(n)!
}
return false // Total: O(n²)
}
// Better: hash set — O(n) total
function hasDuplicatesFast(arr) {
const seen = new Set()
for (const x of arr) {
if (seen.has(x)) return true
seen.add(x)
}
return false // O(n)
}Analyzing Recursive Functions
Recursive functions require you to write and solve a recurrence relation. Express the total work T(n) in terms of smaller inputs.
Recurrence relations
// Linear recursion: T(n) = T(n-1) + O(1) → O(n)
function factorial(n) {
if (n <= 1) return 1
return n * factorial(n - 1) // 1 recursive call, O(1) work
}
// T(n) = T(n-1) + 1 = T(n-2) + 2 = ... = T(1) + n-1 = O(n)
// Binary recursion: T(n) = 2T(n/2) + O(n) → O(n log n)
function mergeSort(arr) {
if (arr.length <= 1) return arr
const mid = Math.floor(arr.length / 2)
const L = mergeSort(arr.slice(0, mid)) // T(n/2)
const R = mergeSort(arr.slice(mid)) // T(n/2)
return merge(L, R) // O(n) merge
}
// By Master Theorem: T(n) = 2T(n/2) + O(n) → O(n log n)
// Binary recursion with O(1) work: T(n) = 2T(n/2) + O(1) → O(n)
function countNodes(root) {
if (!root) return 0
return 1 + countNodes(root.left) + countNodes(root.right)
// Visits every node once: O(n) total
}
// Exponential recursion: T(n) = 2T(n-1) + O(1) → O(2ⁿ)
function fibSlow(n) {
if (n <= 1) return n
return fibSlow(n - 1) + fibSlow(n - 2) // 2 calls, each size n-1 and n-2
}The Master Theorem
For recurrences of the form T(n) = a·T(n/b) + O(nᵈ), the Master Theorem gives:
- If d > log_b(a): T(n) = O(nᵈ)
- If d = log_b(a): T(n) = O(nᵈ · log n)
- If d < log_b(a): T(n) = O(n^log_b(a))
The three cases correspond to the "work at the root dominates", "work is balanced across all levels", and "work at the leaves dominates".
Recurrence | a | b | d | Result | Example |
|---|---|---|---|---|---|
T(n) = 2T(n/2) + O(n) | 2 | 2 | 1 | O(n log n) | Merge sort |
T(n) = 2T(n/2) + O(1) | 2 | 2 | 0 | O(n) | Tree traversal |
T(n) = T(n/2) + O(1) | 1 | 2 | 0 | O(log n) | Binary search |
T(n) = 4T(n/2) + O(n) | 4 | 2 | 1 | O(n²) | Strassen naive |
T(n) = T(n-1) + O(n) | 1 | n/a | n/a | O(n²) | Selection sort |
Common Patterns and Their Complexities
These patterns appear constantly in interview problems. Recognizing them on sight is what separates experienced engineers from beginners.
Code Pattern | Complexity | Reasoning |
|---|---|---|
| O(n) | n iterations |
| O(n) | n/2 iterations → O(n) |
| O(log n) | i doubles each time |
Nested loops both 0..n | O(n²) | n × n iterations |
| O(log n) | halves each step |
Recursion: T(n) = T(n-1) + O(1) | O(n) | linear recursion |
Recursion: T(n) = 2T(n/2) + O(n) | O(n log n) | Master Theorem |
Binary search in sorted array | O(log n) | halves search space |
Hash map/set insert or lookup | O(1) average | hash function |
Sorting n elements | O(n log n) | comparison sort lower bound |
Analyzing String and Array Problems
String and array complexity examples
// Reversing a string — O(n)
function reverse(s) {
return s.split('').reverse().join('')
// split: O(n), reverse: O(n), join: O(n)
// Total: O(3n) = O(n)
}
// Checking if two strings are anagrams — O(n)
function isAnagram(s, t) {
if (s.length !== t.length) return false
const freq = {}
for (const ch of s) freq[ch] = (freq[ch] || 0) + 1 // O(n)
for (const ch of t) { // O(n)
if (!freq[ch]) return false
freq[ch]--
}
return true // O(n) total
}
// Longest unique substring — sliding window O(n)
function lengthOfLongestSubstring(s) {
const seen = new Set()
let left = 0, maxLen = 0
for (let right = 0; right < s.length; right++) { // right pointer: n moves
while (seen.has(s[right])) {
seen.delete(s[left++]) // left pointer: at most n moves total
}
seen.add(s[right])
maxLen = Math.max(maxLen, right - left + 1)
}
return maxLen
// Both pointers move at most n times total: O(n)
}
// Naive substring search — O(n × m)
function naiveSearch(text, pattern) {
for (let i = 0; i <= text.length - pattern.length; i++) { // O(n)
let match = true
for (let j = 0; j < pattern.length; j++) { // O(m)
if (text[i + j] !== pattern[j]) { match = false; break }
}
if (match) return i
}
return -1
// O(n × m) — use KMP for O(n + m)
}Analyzing Tree Problems
Tree traversal and search complexities
// Inorder traversal — O(n): visits every node exactly once
function inorder(root, result = []) {
if (!root) return result
inorder(root.left, result)
result.push(root.val)
inorder(root.right, result)
return result
}
// Height of a binary tree — O(n): visits every node
function height(root) {
if (!root) return 0
return 1 + Math.max(height(root.left), height(root.right))
}
// Binary Search Tree lookup — O(h) where h = height
// Balanced BST: h = log n → O(log n)
// Skewed BST: h = n → O(n)
function bstSearch(root, target) {
if (!root) return null
if (root.val === target) return root
if (target < root.val) return bstSearch(root.left, target)
return bstSearch(root.right, target)
}
// Level-order traversal (BFS) — O(n) time, O(n) space for the queue
function levelOrder(root) {
if (!root) return []
const result = [], queue = [root]
while (queue.length) {
const level = []
const size = queue.length
for (let i = 0; i < size; i++) {
const node = queue.shift()
level.push(node.val)
if (node.left) queue.push(node.left)
if (node.right) queue.push(node.right)
}
result.push(level)
}
return result
}Real Interview Problem: Finding All Subsets
Subsets — O(n × 2ⁿ) explained
// Generate all subsets of an array
function subsets(nums) {
const result = [[]]
for (const num of nums) { // n iterations
const newSubsets = result.map( // result grows: 1, 2, 4, ..., 2^(n-1) subsets
subset => [...subset, num]
)
result.push(...newSubsets)
}
return result
}
// Analysis:
// After processing element i, result has 2^i subsets
// Total work = 2^0 + 2^1 + ... + 2^(n-1) = 2^n - 1
// Each subset copy costs O(n) in the worst case
// Total time: O(n × 2^n)
// Total space: O(n × 2^n) — must store all subsets
console.log(subsets([1, 2, 3]))
// [[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]] — 2^3 = 8 subsets[ [], [ 1 ], [ 2 ], [ 1, 2 ], [ 3 ], [ 1, 3 ], [ 2, 3 ], [ 1, 2, 3 ] ]
How Input Size Affects Your Approach
Experienced engineers map input constraints to expected complexity in seconds. Use this table as a guide in interviews.
n (input size) | Max acceptable complexity | Example algorithm |
|---|---|---|
n ≤ 10 | O(n!) or O(2ⁿ) | Brute force, permutations |
n ≤ 20 | O(2ⁿ) | Bitmask DP, all subsets |
n ≤ 100 | O(n³) | Floyd-Warshall, matrix ops |
n ≤ 1,000 | O(n²) | Bubble sort, DP with nested loops |
n ≤ 100,000 | O(n log n) | Merge sort, balanced BST |
n ≤ 1,000,000 | O(n) | Hash map, sliding window |
n ≤ 10⁹ | O(log n) or O(1) | Binary search, math formula |
Edge Cases That Change Complexity
String concatenation in a loop —
str += charin a loop is O(n²) in languages where strings are immutable (Python, Java). Collect into an array and join at the end for O(n).Sorting a nearly-sorted array — Timsort exploits existing order and approaches O(n) for nearly-sorted inputs, though the worst case is still O(n log n).
Hash collisions — hash map lookup is O(1) average but O(n) worst case if all keys hash to the same bucket.
Graph problems — complexity is often O(V + E) where V = vertices and E = edges. For dense graphs (E ≈ V²), this becomes O(V²).
String problems — the "n" is usually the string length. For two strings of lengths n and m, it is O(n + m) or O(n × m) depending on the algorithm.