📈 Big-O Notation Fundamentals

🎯 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's used to classify algorithms based on how their running time or space requirements grow as the input size grows.

Asymptotic Upper Bound | Worst-Case Analysis | Growth Rate Classification

Why Big-O Matters

Core Insight: Big-O helps us compare algorithm efficiency independently of hardware, programming language, or implementation details. It answers: "How does the algorithm scale as input size grows?" An O(n) algorithm will eventually outperform any O(n²) algorithm for sufficiently large inputs.

Analogy: If sorting 1000 items takes 1 second with an O(n log n) algorithm, an O(n²) algorithm might take ~16 minutes, and an O(2^n) algorithm would take longer than the age of the universe!

📌 Asymptotic Analysis📌 Worst-Case Bound📌 Growth Rate📌 Algorithm Comparison
# Big-O Comparison for n = 1,000,000 # O(1) - Constant: ~1 operation def constant_time(arr): return arr[0] if arr else None # O(log n) - Logarithmic: ~20 operations 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 # O(n) - Linear: ~1,000,000 operations def linear_search(arr, target): for i, val in enumerate(arr): if val == target: return i return -1 # O(n log n) - Linearithmic: ~20,000,000 operations def efficient_sort(arr): return sorted(arr) # Timsort is O(n log n) # O(n²) - Quadratic: ~1,000,000,000,000 operations def bubble_sort(arr): n = len(arr) for i in range(n): for j in range(n - i - 1): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] return arr

📐 Formal Mathematical Definition

🎯 The Mathematical Formulation

f(n) = O(g(n)) if there exist positive constants c and n₀ such that for all n ≥ n₀, f(n) ≤ c · g(n).

In plain English: f(n) is bounded above by g(n) times some constant for all sufficiently large n. This captures the idea that g(n) is an asymptotic upper bound for f(n).

  • c: A constant factor (accounts for implementation differences)
  • n₀: A threshold (we only care about large inputs)
  • "Asymptotically": We ignore small input behavior

Example: Proving n² + 2n + 1 = O(n²)

For n ≥ 1, n² + 2n + 1 ≤ n² + 2n² + n² = 4n². So choose c = 4, n₀ = 1. Therefore n² + 2n + 1 = O(n²). The lower-order terms (2n + 1) become insignificant as n grows.

⏱️ Time Complexities Hierarchy

🎯 Growth Rates from Best to Worst

O(1)
Constant
O(log n)
Logarithmic
O(n)
Linear
O(n log n)
Linearithmic
O(n²)
Quadratic
O(2^n)
Exponential
O(n!)
Factorial

Numerical Comparison (n = 1000)

  • log₂(1000) ≈ 10 operations
  • n = 1000 operations
  • n log n ≈ 10,000 operations
  • n² = 1,000,000 operations
  • 2^n ≈ 10³⁰¹ operations (impossible)

📊 O(1) - Constant Time

🎯 Independent of Input Size

An algorithm runs in O(1) time if it always takes the same number of operations regardless of input size. These are the fastest possible algorithms.

  • Examples: Array indexing, hash table lookup, arithmetic operations, push/pop from stack
  • Real-world: Accessing the first element of an array, checking if a number is even
# O(1) - Constant Time Examples def constant_examples(arr, hashmap): # Array indexing - O(1) first_element = arr[0] if arr else None # Hash map lookup - O(1) average value = hashmap.get("key") # Arithmetic operations - O(1) sum = 5 + 3 product = a * b # Return fixed value - O(1) return first_element

📈 O(log n) - Logarithmic Time

🎯 Halving the Problem Size

O(log n) algorithms are extremely efficient. They repeatedly divide the problem size by a constant factor (usually 2), so the number of operations grows very slowly — even for massive inputs.

  • Examples: Binary search, operations on balanced BSTs (search, insert), finding highest power of 2 ≤ n
  • Real-world: Searching a phone book (binary search), finding an element in a sorted array
# O(log n) - Binary Search 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 # Base of logarithm doesn't matter: O(log₂ n) = O(log₁₀ n) = O(log n)

📏 O(n) - Linear Time

🎯 Proportional to Input Size

O(n) algorithms perform a constant amount of work per input element. As input size doubles, running time doubles — efficient and often optimal for problems requiring examining all data.

  • Examples: Linear search, finding min/max, array traversal, summing all elements
  • Real-world: Finding a name in an unsorted list, reading a file
# O(n) - Linear Time Examples def linear_examples(arr): # Sum all elements - O(n) total = 0 for num in arr: total += num # Find maximum - O(n) max_val = arr[0] if arr else None for num in arr: if num > max_val: max_val = num # Single pass through array - O(n) return total, max_val

🔄 O(n log n) - Linearithmic Time

🎯 The "Good" Sorting Complexity

O(n log n) algorithms are typical for efficient sorting algorithms and divide-and-conquer approaches. This is often the best achievable complexity for comparison-based sorting.

  • Examples: Merge sort, quicksort (average case), heapsort, Timsort
  • Real-world: Sorting a list of names, database indexing operations
# O(n log n) - Merge Sort def merge_sort(arr): if len(arr) <= 1: return arr mid = len(arr) // 2 left = merge_sort(arr[:mid]) right = merge_sort(arr[mid:]) return merge(left, right) def merge(left, right): result = [] i, j = 0, 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

⬛ O(n²) - Quadratic Time

🎯 Nested Loops Dominate

O(n²) algorithms are generally only feasible for small inputs (n < ~10,000). Doubling input size quadruples running time. Common with nested loops where each does O(n) work.

  • Examples: Bubble sort, insertion sort (worst case), selection sort, checking for duplicates naively
  • Real-world: Comparing every pair of items in a list
# O(n²) - Quadratic Time Examples def quadratic_examples(arr): # Nested loop - O(n²) n = len(arr) for i in range(n): for j in range(n): # Constant work inside pass # Bubble sort - O(n²) for i in range(n): for j in range(n - i - 1): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] return arr

💥 O(2^n) - Exponential Time

🎯 Solving the Subset Problem

O(2^n) algorithms become unusable for n > ~30-40. Each new input element doubles running time. These often arise from recursive algorithms that solve two subproblems of size n-1.

  • Examples: Recursive Fibonacci (naive), generating all subsets, traveling salesman (naive)
  • Real-world: Brute-force password cracking, naive Sudoku solvers
# O(2^n) - Fibonacci (naive recursive) def fib_exponential(n): if n <= 1: return n return fib_exponential(n - 1) + fib_exponential(n - 2) # This calls itself twice per level → 2^n operations

🎲 O(n!) - Factorial Time

🎯 The Worst Common Complexity

O(n!) algorithms are catastrophically slow, impractical even for n > 10-12. They arise from generating all permutations of n elements.

  • Examples: Traveling salesman (brute force), generating all permutations, Hamiltonian path brute force
  • Real-world: Solving a 10-city TSP via brute force (3.6 million permutations)
# O(n!) - Generating all permutations def generate_permutations(arr, start=0, result=None): if result is None: result = [] if start == len(arr): result.append(arr[:]) return result for i in range(start, len(arr)): arr[start], arr[i] = arr[i], arr[start] generate_permutations(arr, start + 1, result) arr[start], arr[i] = arr[i], arr[start] return result

💾 Space Complexity Analysis

🎯 Memory Usage Analysis

Space complexity measures how much extra memory an algorithm needs relative to input size. It's analyzed using the same Big-O notation as time complexity.

  • O(1) space: Sorting in-place (e.g., bubble sort, insertion sort)
  • O(n) space: Creating a copy of the array (e.g., merge sort), recursion stack for depth n
  • O(n²) space: Dynamic programming tables (e.g., Floyd-Warshall)
# Space Complexity Examples def space_examples(arr): # O(1) extra space - only a few variables total = 0 for num in arr: total += num # O(n) extra space - creating a new array new_arr = [x * 2 for x in arr] # O(n) recursion stack space def recursive_sum(arr, n): if n == 0: return 0 return arr[n-1] + recursive_sum(arr, n-1) return total

📝 Other Notations (Ω, Θ, o, ω)

🎯 Asymptotic Notation Family

  • Big-O (O): Upper bound (worst-case) - "at most"
  • Big-Omega (Ω): Lower bound (best-case) - "at least"
  • Big-Theta (Θ): Tight bound (both upper and lower) - "exactly"
  • Little-o (o): Strict upper bound - grows strictly slower
  • Little-omega (ω): Strict lower bound - grows strictly faster

Why Use Big-O Most Often?

While Ω describes best-case and Θ describes average/tight cases, we typically care about worst-case performance (Big-O) because it provides guarantees. However, for some algorithms (like quicksort), Θ(n log n) average case is more informative than O(n²) worst case.

📋 Rules & Simplifications

🎯 How to Calculate Big-O

  • Drop constants: O(2n) = O(n), O(100) = O(1)
  • Keep the fastest-growing term: O(n² + n) = O(n²)
  • Addition rule: Sequential operations add: O(f) + O(g) = O(max(f, g))
  • Multiplication rule: Nested loops multiply: O(f) × O(g) = O(f × g)
  • Logarithm base doesn't matter: O(log₂ n) = O(log₁₀ n) = O(log n)
# Simplification Examples def analyze_complexity(n): # Loop 1: O(n) for i in range(n): pass # Loop 2: O(n) for i in range(n): pass # Total sequential: O(n) + O(n) = O(n) # Nested loops: O(n²) for i in range(n): for j in range(n): pass # Loop with constant: O(n) + O(1) = O(n) for i in range(n): pass return 42 # O(1) # Final complexity: O(n²) (dominant term)

💪 Practice & Analysis Patterns

🎯 Common Patterns to Recognize

  • Single loop: Usually O(n)
  • Nested loops: Usually O(n²) or O(n × m)
  • Divide and conquer (halving): Usually O(log n) or O(n log n)
  • Recursion with 2 calls (n-1): Usually O(2^n)
  • Recursion with 1 call (n/2): Usually O(log n)
  • While loop with halving counter: O(log n)
# Practice: Analyze these functions def func1(arr): # What's the complexity? n = len(arr) for i in range(n): for j in range(n): print(arr[i] + arr[j]) # Answer: O(n²) def func2(n): # What's the complexity? count = 0 while n > 0: n //= 2 count += 1 return count # Answer: O(log n) def func3(n): # What's the complexity? if n <= 1: return 1 return func3(n-1) + func3(n-1) # Answer: O(2^n) def func4(arr): # What's the complexity? result = [] for i in range(len(arr)): for j in range(i, len(arr)): result.append(arr[i] + arr[j]) return result # Answer: O(n²) (sum of 1..n = n(n+1)/2) def func5(matrix): # What's the complexity? (matrix is n x n) total = 0 for i in range(len(matrix)): for j in range(len(matrix[0])): total += matrix[i][j] return total # Answer: O(n²) - iterates over all n² elements