Loading content...
While infinitely many growth functions exist, a handful of complexity classes dominate algorithm analysis. These classes form the vocabulary of efficiency discussions—when engineers debate approaches, they speak in terms of O(n) versus O(n log n), not 3.7n + 42 versus 2.1n log n + 17.
Mastering these classes means developing intuition for:
This page provides that intuition through exhaustive exploration of each major complexity class, from the lightning-fast O(1) to the effectively-impossible O(n!).
By the end of this page, you will deeply understand O(1), O(log n), O(n), O(n log n), O(n²), O(n³), O(2ⁿ), and O(n!). For each, you'll know what produces it, what it means practically, and canonical examples. You'll develop the intuition to immediately classify algorithms and predict their behavior at scale.
Definition:
An algorithm is O(1) if its runtime doesn't depend on input size. Whether the input has 10 elements or 10 billion, the operation takes (roughly) the same time.
The intuition:
O(1) operations go directly to what they need—no searching, no iteration, no recursion through input. They use structural properties (like indices or hashes) to access data in a fixed number of steps.
What O(1) does NOT mean:
| Operation | Why O(1) | Caveat |
|---|---|---|
| Array access: arr[i] | Direct memory address calculation | Index must be known |
| Hash table lookup (average) | Hash + direct bucket access | Worst case is O(n) |
| Stack push/pop | Modify only the top | Fixed pointer operations |
| Linked list insert at known position | Pointer reassignment | Finding the position may not be O(1) |
| Get array length | Stored metadata | Some languages compute O(n) |
| Arithmetic operations | Single CPU instruction | For fixed-size integers only |
How to recognize O(1) in code:
# O(1) - Direct access
value = array[index]
# O(1) - Fixed operations
def swap(a, b):
return b, a
# O(1) - Hash table (average case)
def lookup(hashmap, key):
return hashmap[key]
# NOT O(1) - This is O(n)
def find_max(array):
return max(array) # Must examine all elements
Scaling behavior:
| Input Size | Operations |
|---|---|
| 10 | k (some constant) |
| 1,000 | k |
| 1,000,000 | k |
| 1,000,000,000 | k |
O(1) is the gold standard—if you can achieve it, do so.
O(1) is the ideal complexity for any individual operation. Many advances in data structures are about achieving O(1) for operations that would otherwise require O(n) or O(log n). When designing systems, ask: 'Can I restructure data to make this O(1)?'
Definition:
An algorithm is O(log n) if it reduces the problem size by a constant factor at each step. Each step eliminates a fraction of remaining possibilities.
The intuition:
Logarithmic algorithms don't examine every element—they repeatedly 'cut in half' (or thirds, or tenths). Binary search is the canonical example: with each comparison, half the remaining elements are eliminated.
The magic of logarithms:
log₂(n) grows incredibly slowly:
To search 1 billion sorted elements with binary search takes only ~30 comparisons!
| Input Size (n) | log₂(n) Operations | Increase Factor |
|---|---|---|
| 10 | ~3 | — |
| 100 | ~7 | 2.3x for 10x data |
| 1,000 | ~10 | 1.4x for 10x data |
| 1,000,000 | ~20 | 2x for 1000x data |
| 1,000,000,000 | ~30 | 1.5x for 1000x data |
What produces O(log n):
while n > 0: n = n // 2Canonical examples:
# Binary search - O(log n)
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
# Halving loop - O(log n)
def count_halves(n):
count = 0
while n > 0:
n = n // 2
count += 1
return count # Returns floor(log₂(n)) + 1
For most practical purposes, O(log n) behaves almost like O(1). The difference between 20 and 30 operations is negligible. This is why balanced BSTs and binary search are so powerful—their 'slow' case is still blazingly fast.
Definition:
An algorithm is O(n) if it performs a constant amount of work for each input element. Double the input, double the work.
The intuition:
Linear algorithms must examine every element at least once—they 'touch' each piece of input but don't repeatedly revisit elements. A single pass through data is the hallmark of linear algorithms.
Scaling behavior:
| Input Size | Work |
|---|---|
| 1,000 | 1,000 operations |
| 10,000 | 10,000 operations |
| 1,000,000 | 1,000,000 operations |
| Algorithm | Why O(n) | Description |
|---|---|---|
| Linear search | Check each element once | Find target in unsorted array |
| Sum of array | Visit each element once | Add up all values |
| Find max/min | Compare each element once | Track best seen so far |
| Count occurrences | One pass through data | Count how many times x appears |
| Copy an array | Copy each element once | Create duplicate structure |
| Counting sort (with small k) | One pass to count, one to output | Sort when values in known range |
How to recognize O(n) in code:
# Single loop over input - O(n)
for element in array:
process(element) # O(1) per element
# Two sequential loops - still O(n)
for element in array:
do_something(element)
for element in array:
do_something_else(element)
# O(n) + O(n) = O(2n) = O(n)
# Sum calculation - O(n)
def sum_array(arr):
total = 0
for x in arr:
total += x
return total
O(n) is often optimal:
For many problems, O(n) is the best possible:
When a problem requires examining all input, O(n) is the achievable optimum.
When solving problems, often the goal is: 'Can I do this in a single pass?' If yes, you likely have O(n). Many interview problems have O(n) optimal solutions that require recognizing how to gather all needed information in one traversal.
Definition:
An algorithm is O(n log n) if it processes n elements, and for each element (or each level of recursion), it does O(log n) work—or equivalently, it has O(log n) levels of recursion/iteration, each doing O(n) work.
The intuition:
O(n log n) is slightly worse than linear but dramatically better than quadratic. It's the complexity of 'smart' sorting and divide-and-conquer algorithms that split the problem but must still examine all elements.
Why n log n appears:
| Input Size | O(n) | O(n log n) | Ratio |
|---|---|---|---|
| 1,000 | 1,000 | ~10,000 | 10x |
| 10,000 | 10,000 | ~132,000 | 13x |
| 1,000,000 | 1,000,000 | ~20,000,000 | 20x |
| 1,000,000,000 | 1,000,000,000 | ~30,000,000,000 | 30x |
Canonical examples:
# Merge sort - O(n log n)
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # T(n/2)
right = merge_sort(arr[mid:]) # T(n/2)
return merge(left, right) # O(n)
# Recurrence: T(n) = 2T(n/2) + O(n) = O(n log n)
# Heap sort - O(n log n)
def heap_sort(arr):
build_heap(arr) # O(n)
for i in range(n):
extract_max(arr) # O(log n) each, n times
# Total: O(n) + O(n log n) = O(n log n)
The sorting barrier:
O(n log n) is the lower bound for comparison-based sorting. No comparison sort can do better than Θ(n log n) in the average case. This is why merge sort, heap sort, and quicksort (average case) all hit this bound—they're optimal.
When you see 'sorted' in a problem statement and the solution is O(n log n), sorting is likely involved. The n log n often comes from a preprocessing sort step. Many algorithms are O(n) after sorting, making the total O(n log n) due to the sort.
Definition:
An algorithm is O(n²) if it examines every pair of elements, or performs O(n) work for each of n elements.
The intuition:
Quadratic algorithms compare or combine elements pairwise. When you see nested loops where both iterate over input size, expect O(n²).
Scaling behavior (the problem):
| Input Size | Operations | Time at 1M ops/sec |
|---|---|---|
| 100 | 10,000 | 0.01 seconds |
| 1,000 | 1,000,000 | 1 second |
| 10,000 | 100,000,000 | 100 seconds |
| 100,000 | 10,000,000,000 | ~3 hours |
This is why O(n²) becomes problematic at scale. What's fine for 100 elements becomes impossibly slow at 100,000.
What produces O(n²):
# Nested loops over same input - O(n²)
for i in range(n):
for j in range(n):
process(i, j)
# Bubble sort - O(n²)
for i in range(n):
for j in range(n - 1 - i):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
# Checking all pairs - O(n²)
for i in range(n):
for j in range(i + 1, n):
if has_property(arr[i], arr[j]):
count += 1
| Algorithm | Why O(n²) | Better Alternative |
|---|---|---|
| Bubble sort | Compare adjacent pairs, n passes | Merge sort: O(n log n) |
| Selection sort | Find min n times, each O(n) | Heap sort: O(n log n) |
| Insertion sort (worst) | Insert each element, worst O(n) | OK for small/nearly sorted |
| Naive string matching | Check pattern at each position | KMP: O(n + m) |
| All pairs shortest path (naive) | BFS from each vertex | Floyd-Warshall for dense graphs |
Nested loops are seductive—they're easy to write and easy to understand. But they're often a sign that a better approach exists. Before accepting O(n²), ask: 'Is there a way to avoid examining every pair?' Sorting, hashing, and clever data structures often reduce O(n²) to O(n log n) or O(n).
Definition:
O(n³) algorithms perform cubic work—typically examining all triples of elements or having three nested loops.
The intuition:
Each additional exponent makes scaling dramatically worse. O(n³) processes triples, like checking for three-element relationships.
Scaling reality:
| Input Size | O(n²) | O(n³) | O(n⁴) |
|---|---|---|---|
| 100 | 10,000 | 1,000,000 | 100,000,000 |
| 1,000 | 1,000,000 | 1,000,000,000 | 10¹² |
O(n³) is feasible for n up to a few thousand. Beyond that, it becomes impractical quickly.
What produces O(n³):
# Three nested loops - O(n³)
for i in range(n):
for j in range(n):
for k in range(n):
process(i, j, k)
# Matrix multiplication (naive) - O(n³)
for i in range(n):
for j in range(n):
for k in range(n):
C[i][j] += A[i][k] * B[k][j]
# Floyd-Warshall all-pairs shortest path - O(n³)
for k in range(n):
for i in range(n):
for j in range(n):
dist[i][j] = min(dist[i][j],
dist[i][k] + dist[k][j])
Polynomial time in general:
Algorithms that are O(nᵏ) for any fixed constant k are called 'polynomial-time.' This includes O(n), O(n²), O(n³), etc. Polynomial-time algorithms are generally considered 'tractable' because they scale predictably (if uncomfortably for high exponents).
In theoretical CS, polynomial-time (O(nᵏ) for any constant k) is considered 'efficient,' while exponential time is 'intractable.' The famous P vs NP problem asks whether certain problems can be solved in polynomial time. For practice, O(n²) is borderline; O(n³) requires careful consideration; O(n⁴) and beyond are rarely acceptable at scale.
Definition:
An algorithm is O(2ⁿ) if its runtime doubles with each unit increase in input size. Adding one element doubles the work.
The intuition:
Exponential algorithms explore all possible subsets or combinations. With n elements, there are 2ⁿ possible subsets—exponential algorithms consider them all.
The scaling disaster:
| Input Size | 2ⁿ Operations | Time at 1 billion ops/sec |
|---|---|---|
| 10 | 1,024 | instant |
| 20 | ~1,000,000 | 1 millisecond |
| 30 | ~1,000,000,000 | 1 second |
| 40 | ~1 trillion | 18 minutes |
| 50 | ~1 quadrillion | 13 days |
| 60 | ~1 quintillion | 36 years |
| 100 | ~10³⁰ | longer than universe age |
This is why exponential algorithms are called 'intractable.' They work for tiny inputs but become impossible beyond n ≈ 20-40.
What produces O(2ⁿ):
# Generate all subsets - O(2ⁿ)
def all_subsets(arr):
if not arr:
return [[]]
first = arr[0]
rest_subsets = all_subsets(arr[1:])
return rest_subsets + [[first] + s for s in rest_subsets]
# Naive Fibonacci - O(2ⁿ) due to overlapping calls
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
# Forms a binary tree of calls
# Brute force knapsack - O(2ⁿ)
def knapsack_brute(weights, values, capacity):
# Try all 2ⁿ subsets of items
return max(value for subset in all_subsets(range(n))
if sum(weights[i] for i in subset) <= capacity
for value in [sum(values[i] for i in subset)])
The exponential signature:
Exponential recursion often has this pattern:
f(n) calls f(n-1) twice: T(n) = 2T(n-1) = O(2ⁿ)
If you find yourself writing exponential algorithms, ask: 'Is there dynamic programming? Is there a greedy approach? Can I prune the search space?' Many problems that seem to require O(2ⁿ) have clever polynomial solutions. Fibonacci, for instance, is O(n) with memoization or iteration.
Definition:
An algorithm is O(n!) if it considers all possible orderings or permutations of n elements.
The intuition:
n! (n factorial) is the number of ways to arrange n distinct elements. It grows even faster than 2ⁿ:
Scaling horror:
| n | n! | Compared to 2ⁿ |
|---|---|---|
| 10 | 3.6 million | 3,500x more |
| 15 | 1.3 trillion | 40 million x more |
| 20 | 2.4 quintillion | 2.3 billion x more |
What produces O(n!):
# Generate all permutations - O(n!)
def permutations(arr):
if len(arr) <= 1:
return [arr]
result = []
for i, elem in enumerate(arr):
rest = arr[:i] + arr[i+1:]
for perm in permutations(rest):
result.append([elem] + perm)
return result
# Brute force traveling salesman - O(n!)
def tsp_brute(cities):
best = infinity
for route in permutations(cities):
cost = route_cost(route)
best = min(best, cost)
return best
When O(n!) appears:
Practical limit:
O(n!) algorithms are practical for n ≤ 10-12. Beyond that, even supercomputers can't help.
Factorial algorithms are essentially stating 'I've given up on efficiency.' Sometimes they're necessary—some NP-hard problems have no known polynomial solutions. But always look for approximations, heuristics, or dynamic programming before accepting O(n!).
Let's consolidate the complexity landscape with a comprehensive comparison:
| Class | Name | Example | n = 1,000,000 |
|---|---|---|---|
| O(1) | Constant | Array access | 1 op |
| O(log n) | Logarithmic | Binary search | ~20 ops |
| O(n) | Linear | Array sum | 1,000,000 ops |
| O(n log n) | Linearithmic | Merge sort | ~20,000,000 ops |
| O(n²) | Quadratic | Bubble sort | 10¹² ops |
| O(n³) | Cubic | Floyd-Warshall | 10¹⁸ ops |
| O(2ⁿ) | Exponential | Subset generation | Impossible |
| O(n!) | Factorial | All permutations | Way impossible |
What's next:
Numbers and tables provide understanding, but intuition comes from seeing how these complexities play out in real systems. The next page builds visual and practical intuition—graphs that show the dramatic differences, real-world analogies, and guidelines for what's acceptable in different contexts.
You now know the major complexity classes that dominate algorithm analysis. You understand what produces each, how they scale, and when each is acceptable. Next, we'll cement this knowledge with visual intuition and real-world context.