Big o notation
Big O Notation Explained
A comprehensive guide to understanding algorithm complexity and performance analysis
Table of Contents
- What is Big O Notation?
- Time & Space Complexity
- Growth Rate Analysis
- Common Big O Notations
- Practical Examples
- Advanced Concepts
- Best Practices
What is Big O Notation?
Big O notation is a mathematical notation that describes the limiting behavior of a function when the argument tends towards a particular value or infinity. In computer science, it classifies algorithms according to how their running time or space requirements grow as the input size grows.
Core Principles
Big O notation provides several key insights:
- Worst-case analysis: Describes the maximum time/space an algorithm will need
- Growth rate focus: Emphasizes how performance scales, not absolute performance
- Hardware independence: Allows comparison across different computing environments
- Asymptotic behavior: Shows performance trends as input size approaches infinity
Mathematical Foundation
For a function f(n), 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₀
This means g(n) provides an upper bound for f(n) as n grows large.
Time & Space Complexity
Understanding the two primary dimensions of algorithm analysis:
Time Complexity
Definition: Measures the number of operations an algorithm performs as a function of input size n.
| Aspect | Description | Example |
|---|---|---|
| Best Case | Minimum time needed | Finding target as first element |
| Average Case | Expected time for typical input | Finding target in middle of array |
| Worst Case | Maximum time needed | Target not in array or last element |
Key Considerations:
- Focuses on fundamental operations (comparisons, assignments)
- Ignores constant factors and lower-order terms
- Provides scalability insights for large datasets
Space Complexity
Definition: Measures the amount of memory an algorithm requires relative to input size.
Components:
- Input space: Memory for the input data
- Auxiliary space: Extra memory used by algorithm
- Output space: Memory for the result
| Space Type | Example | Complexity |
|---|---|---|
| Constant | Few variables regardless of input | O(1) |
| Linear | Array copy of input | O(n) |
| Quadratic | 2D matrix for n×n problem | O(n²) |
Relationship Between Time and Space
Often there’s a time-space tradeoff:
- More space → Potentially faster execution (memoization)
- Less space → Potentially slower execution (recalculation)
Growth Rate Analysis
Understanding how different complexities scale with input size:
Growth Rate Comparison
| Input Size (n) | O(1) | O(log n) | O(n) | O(n log n) | O(n²) | O(2ⁿ) |
|---|---|---|---|---|---|---|
| 1 | 1 | 0 | 1 | 0 | 1 | 2 |
| 10 | 1 | 3 | 10 | 33 | 100 | 1,024 |
| 100 | 1 | 7 | 100 | 664 | 10,000 | 1.3×10³⁰ |
| 1,000 | 1 | 10 | 1,000 | 9,966 | 1,000,000 | ∞ |
| 10,000 | 1 | 13 | 10,000 | 132,877 | 100,000,000 | ∞ |
Visual Growth Representation

Performance Categories
| Category | Complexity | Performance | Scalability |
|---|---|---|---|
| Excellent | O(1), O(log n) | Fast | Excellent |
| Good | O(n), O(n log n) | Reasonable | Good |
| Poor | O(n²), O(n³) | Slow for large n | Limited |
| Terrible | O(2ⁿ), O(n!) | Impractical | None |
Common Big O Notations
O(1) - Constant Time
Characteristics:
- Performance independent of input size
- Most efficient possible complexity
- Common in hash table lookups, array indexing
Examples:
def get_first_element(arr):
return arr[0] # Always one operation
def hash_lookup(dictionary, key):
return dictionary[key] # Hash table access
Real-world Applications:
- Database primary key lookup
- Stack push/pop operations
- Direct memory access
O(log n) - Logarithmic Time
Characteristics:
- Very efficient for large datasets
- Often involves dividing problem in half
- Common in tree traversals, binary search
Binary Search Example:
def binary_search(sorted_arr, target):
left, right = 0, len(sorted_arr) - 1
while left <= right:
mid = (left + right) // 2
if sorted_arr[mid] == target:
return mid
elif sorted_arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
Why It’s Logarithmic:
- Each comparison eliminates half the remaining elements
- Maximum comparisons = log₂(n)
- For 1,000 elements: only ~10 comparisons needed
O(n) - Linear Time
Characteristics:
- Performance grows proportionally with input
- Must examine each element once
- Often unavoidable for certain problems
Examples:
def find_maximum(arr):
max_val = arr[0]
for element in arr[1:]: # n-1 comparisons
if element > max_val:
max_val = element
return max_val
def linear_search(arr, target):
for i, element in enumerate(arr): # Up to n comparisons
if element == target:
return i
return -1
When It’s Optimal:
- Must process every element (sum, count, validate)
- Cannot make assumptions about data structure
- Often the best possible for unsorted data
O(n log n) - Linearithmic Time
Characteristics:
- Common in efficient sorting algorithms
- Optimal for comparison-based sorting
- Divide-and-conquer approach
Merge Sort Example:
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # Divide: log n levels
right = merge_sort(arr[mid:]) # Divide: log n levels
return merge(left, right) # Conquer: n work per level
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
Why n log n:
- log n levels of recursion (halving each time)
- n work at each level (merging)
- Total: n × log n operations
O(n²) - Quadratic Time
Characteristics:
- Common with nested loops over same dataset
- Performance degrades quickly with size
- Often indicates inefficient algorithm
Examples:
def bubble_sort(arr):
n = len(arr)
for i in range(n): # Outer loop: n iterations
for j in range(0, n-i-1): # Inner loop: up to n iterations
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
def print_all_pairs(arr):
for i in arr: # n iterations
for j in arr: # n iterations each
print(f"({i}, {j})") # n² total pairs
When It Might Be Acceptable:
- Small, fixed-size datasets
- Simple implementation needed
- Performance not critical
O(2ⁿ) - Exponential Time
Characteristics:
- Extremely inefficient for large inputs
- Often indicates brute-force approach
- Common in recursive problems without memoization
Fibonacci Example (Naive):
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2) # Two recursive calls
Why It’s Exponential:
- Each call branches into two more calls
- Creates a binary tree of depth n
- Total calls ≈ 2ⁿ
Better Approach (O(n)):
def fibonacci_optimized(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
Practical Examples
Data Structure Operations
| Operation | Array | Linked List | Hash Table | Binary Search Tree |
|---|---|---|---|---|
| Access | O(1) | O(n) | O(1) avg | O(log n) avg |
| Search | O(n) | O(n) | O(1) avg | O(log n) avg |
| Insert | O(n) | O(1) | O(1) avg | O(log n) avg |
| Delete | O(n) | O(1) | O(1) avg | O(log n) avg |
Sorting Algorithms
| Algorithm | Best Case | Average Case | Worst Case | Space |
|---|---|---|---|---|
| Bubble Sort | O(n) | O(n²) | O(n²) | O(1) |
| Selection Sort | O(n²) | O(n²) | O(n²) | O(1) |
| Insertion Sort | O(n) | O(n²) | O(n²) | O(1) |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) |
| Quick Sort | O(n log n) | O(n log n) | O(n²) | O(log n) |
| Heap Sort | O(n log n) | O(n log n) | O(n log n) | O(1) |
Graph Algorithms
| Algorithm | Time Complexity | Space Complexity |
|---|---|---|
| DFS/BFS | O(V + E) | O(V) |
| Dijkstra | O((V + E) log V) | O(V) |
| Floyd-Warshall | O(V³) | O(V²) |
| Kruskal’s MST | O(E log E) | O(V) |
Advanced Concepts
Amortized Analysis
Definition: Average time per operation over a sequence of operations.
Example - Dynamic Array:
- Most insertions: O(1)
- Occasional resize: O(n)
- Amortized: O(1) per insertion
class DynamicArray:
def __init__(self):
self.data = [None] * 1
self.size = 0
self.capacity = 1
def append(self, item):
if self.size == self.capacity:
self._resize() # O(n) occasionally
self.data[self.size] = item # O(1) usually
self.size += 1
def _resize(self):
self.capacity *= 2
new_data = [None] * self.capacity
for i in range(self.size):
new_data[i] = self.data[i]
self.data = new_data
Big Omega (Ω) and Big Theta (Θ)
Big Omega (Ω): Lower bound (best case)
- Ω(g(n)) means algorithm takes at least g(n) time
Big Theta (Θ): Tight bound
- Θ(g(n)) means algorithm takes exactly g(n) time (both upper and lower bound)
| Notation | Meaning | Relationship |
|---|---|---|
| O(g(n)) | Upper bound | f(n) ≤ c×g(n) |
| Ω(g(n)) | Lower bound | f(n) ≥ c×g(n) |
| Θ(g(n)) | Tight bound | c₁×g(n) ≤ f(n) ≤ c₂×g(n) |
Best Practices
Algorithm Analysis Guidelines
Do:
- Focus on the dominant term (highest order)
- Consider worst-case scenarios
- Analyze both time and space complexity
- Use appropriate data structures
- Consider the expected input characteristics
Don’t:
- Include constant factors in Big O
- Ignore lower-order terms
- Assume best-case performance
- Optimize prematurely without profiling
- Choose complex algorithms for small datasets
Common Optimization Strategies
| Problem | Poor Solution | Better Solution | Improvement |
|---|---|---|---|
| Finding duplicates | Nested loops O(n²) | Hash set O(n) | Quadratic → Linear |
| Sorted array search | Linear scan O(n) | Binary search O(log n) | Linear → Logarithmic |
| Frequent lookups | Array scan O(n) | Hash table O(1) | Linear → Constant |
| Range queries | Recalculate O(n) | Prefix sums O(1) | Linear → Constant |
When to Optimize
Optimize When:
- Performance is measurably insufficient
- Algorithm doesn’t scale to required input size
- Resource constraints are tight
- Bottleneck identified through profiling
Don’t Optimize When:
- Performance is already acceptable
- Code clarity would suffer significantly
- Input sizes will remain small
- Development time is limited
Space-Time Tradeoffs
| Technique | Time Benefit | Space Cost | Use Case |
|---|---|---|---|
| Memoization | Eliminate recalculation | Store intermediate results | Recursive algorithms |
| Precomputation | Faster queries | Precomputed tables | Frequent lookups |
| Caching | Avoid expensive operations | Memory for cache | Repeated computations |
| Index structures | Faster searches | Additional storage | Database queries |
Summary
Big O notation is fundamental to computer science and software engineering. Key takeaways:
Essential Concepts:
- Describes algorithm scalability, not absolute performance
- Focuses on worst-case scenarios and growth rates
- Enables comparison of algorithms independent of hardware
Common Complexities (Best to Worst):
- O(1) - Constant: Hash table lookups
- O(log n) - Logarithmic: Binary search
- O(n) - Linear: Simple iterations
- O(n log n) - Linearithmic: Efficient sorting
- O(n²) - Quadratic: Nested loops
- O(2ⁿ) - Exponential: Brute force recursion
Practical Application:
- Choose appropriate algorithms for your use case
- Consider both time and space requirements
- Profile before optimizing
- Understand the tradeoffs involved
Understanding Big O notation enables you to write more efficient code, make informed algorithmic choices, and build systems that scale effectively with growing data and user demands.