Big O Notation and Algorithmic Complexity

Big O Notation and Algorithmic Complexity

Big O notation measures how the resource requirements of an Algorithm—specifically running time and memory space—grow asymptotically as the input size ($N$) approaches infinity.

Instead of measuring execution in seconds or bytes (which change based on hardware), Big O focuses entirely on the growth rate of the operations relative to input growth.

Fundamental Mathematical Rules

When calculating Big O, you evaluate the total algebraic cost Function $T(N)$ and apply two mathematical simplifications:

1. Drop the Constants

As $N$ grows massive, small scaling factors become mathematically insignificant.

  • Example: $T(N) = 3N + 5 \implies O(N)$
  • Example: $T(N) = \frac{1}{2}N^2 \implies O(N^2)$

2. Drop the Non-Dominant Terms

Focus only on the single term that grows fastest.

  • Example: $T(N) = N^2 + 5N + 100 \implies O(N^2)$
  • Example: $T(N) = N\log N + N^3 \implies O(N^3)$

Step-by-Step Time Complexity Analysis

Strategy A: Iterative Loop Analysis

To analyze iterative algorithms, you map loops to algebraic equations by multiplying outer loops by inner loops.

Single Loops

Python

def print_items(n):
    for i in Range(n):
        print(i)  # Executes N times
  • Analysis: The Basic print operation executes exactly $N$ times.
  • Complexity: $O(N)$ (Linear Time)

Nested loops (Independent)

Python

def print_pairs(n):
    for i in Range(n):         # Runs N times
        for j in Range(n):     # Runs N times for every single outer Iteration
            print(i, j)
  • Analysis: The total execution cost is $N \times N = N^2$.
  • Complexity: $O(N^2)$ (Quadratic Time)

Nested loops (Dependent)

Python

def print_triangles(n):
    for i in Range(n):         # Runs N times
        for j in Range(i):     # Runs 0, then 1, then 2... up to N-1 times
            print(i, j)
  • Analysis: This forms an arithmetic progression:$$0 + 1 + 2 + \dots + (N-1) = \frac{N(N-1)}{2} = \frac{N^2}{2} – \frac{N}{2}$$
  • Applying Rules: Drop constants and non-dominant terms.
  • Complexity: $O(N^2)$ (Quadratic Time)

Strategy B: Logarithmic Divergence (Divide and Conquer)

When an Algorithm cuts its remaining Data processing pool in half during each Iteration, it exhibits logarithmic scale growth.

Python

def binary_search(sorted_list, target):
    low = 0
    high = len(sorted_list) - 1
    
    while low <= high:
        mid = (low + high) // 2
        if sorted_list[mid] == target:
            return mid
        elif sorted_list[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
    return -1
  • Analysis: With each Array entry comparison, the remaining search window drops from $N \to \frac{N}{2} \to \frac{N}{4} \to \frac{N}{8}$.
  • Calculation: How many times ($k$) can we divide $N$ by 2 before reaching 1?$$\frac{N}{2^k} = 1 \implies N = 2^k \implies k = \log_2 N$$
  • Complexity: $O(\log N)$ (Logarithmic Time)

Strategy C: Recursive Call Trees

For recursive procedures, map out the branch Count per stack split and multiply it by the depth of the tree structure.

Python

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
  • Analysis: Every structural step down invokes two additional functions, doubling calls dynamically. The execution depth reaches a height of $N$.
  • Calculation: Total branches = $2^0 + 2^1 + 2^2 + \dots + 2^N \approx 2^N$.
  • Complexity: $O(2^N)$ (Exponential Time)

Step-by-Step Space Complexity Analysis

Space complexity tracks the additional maximum memory footprint allocated by an Algorithm during runtime, relative to input scale. It includes both Variables stored in memory heap space and active stacks frame states.

1. Auxiliary Allocation Space

Memory assigned directly for Variable manipulation arrays.

Python

def generate_lookup(n):
    arr = []
    for i in Range(n):
        arr.append(i * 2)  # Dynamically scales allocations up to N elements
    return arr
  • Analysis: The Function allocates an Array holding $N$ distinct values inside memory.
  • Complexity: $O(N)$ Space.

2. Call Stack Memory Space

Recursive algorithms take up execution Environment stack space, even if no explicit Variables are declared.

Python

def down_counter(n):
    if n <= 0:
        return
    down_counter(n - 1)  # Keeps frame Context open in execution stack
  • Analysis: Before hitting the recursive base exit Condition ($n \le 0$), the compiler keeps all parental frames open in memory sequence. The stack depth holds exactly $N$ active frames simultaneously.
  • Complexity: $O(N)$ Space.

Cheat Sheet: Standard Complexities Matrix

Big O NotationPerformance ClassTypical Paradigm Example
$O(1)$ConstantAccessing an Array item by index; Hash map lookups.
$O(\log N)$LogarithmicBinary search operations; balanced tree navigation.
$O(N)$LinearSingle pass tracking arrays; linear scans.
$O(N \log N)$LinearithmicOptimized sorting architectures (Merge Sort, Quick Sort).
$O(N^2)$QuadraticBrute-force nested algorithms (Bubble Sort).
$O(2^N)$ExponentialExhaustive recursive calls without optimization.
$O(N!)$FactorialGenerating all permutations of a set.

Guided Practice Case: Two-Sum Analysis

Let’s apply these steps to evaluate two distinct approaches to the classic “Two-Sum” problem.

Problem: Given an Array of integers nums and a target Integer target, return the indices of the two numbers that add up to target.

Method 1: The Brute Force Approach

Python

def two_sum_brute(nums, target):
    n = len(nums)
    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return [i, j]
    return []
  • Time Analysis: A nested Loop structures a dependent series execution: $(N-1) + (N-2) + \dots + 1$. Dropping non-dominant factors yields $O(N^2)$.
  • Space Analysis: Only primitive pointers (i, j, n) are held in memory. Space does not scale based on Array size.
  • Summary: Time: $O(N^2)$, Space: $O(1)$.

Method 2: The Hash Map Optimization

Python

def two_sum_optimized(nums, target):
    seen = {}  # Allocates hash map container
    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return [seen[complement], i]
        seen[num] = i
    return []
  • Time Analysis: We make a single pass through the Array Loop ($N$ items). Inside the Loop, checking if a value exists in a hash map takes constant time ($O(1)$). Total time scales linearly.
  • Space Analysis: In the worst-Case scenario (no matching pairs exist), the seen hash map stores every single element from the input Array.
  • Summary: Time: $O(N)$, Space: $O(N)$.

The Trade-Off Principle: Method 2 optimizes computational Time efficiency (moving from quadratic $O(N^2)$ down to linear $O(N)$) by deliberately investing more physical Space memory overhead ($O(1) \to O(N)$).

Andrew Thawley Avatar