DSA Notes
Master Big O notation with the formal mathematical definition, the four rules for simplifying complexity expressions, step-by-step worked examples, common patterns to recognize instantly, and a comprehensive reference table for every major algorithm.
Introduction
In the previous lesson, you were introduced to the concept of time and space complexity and saw examples of O(1), O(log n), O(n), O(n²), and O(2ⁿ). In this lesson, we go much deeper.
You will learn the formal mathematical definition of Big O notation, the precise rules for simplifying complexity expressions, how to analyze any algorithm step by step, how to handle recursion, and how to build the intuition to recognize common patterns on sight. By the end of this lesson, you will be able to look at any piece of code and determine its time and space complexity with confidence.
The Four Rules for Simplifying Big O
These four rules handle 99% of all complexity analysis situations.
Rule 1 — Drop All Multiplicative Constants
If the exact operation count contains a multiplicative constant, drop it.
| 6n | O(n) |
| 100n | O(n) |
| n/2 | O(n) [1/2 is a constant coefficient] |
| 3n² | O(n²) |
| 0.001n³ | O(n³) |
Why? Because the constant c in the formal definition absorbs it. f(n) = 100n ≤ 101 · n for all n ≥ 1.
Rule 2 — Keep Only the Dominant Term
If the operation count is a sum of multiple terms, keep only the one that grows fastest.
| n² + n | O(n²) [n² dominates n for large n] |
| n³ + n² + n | O(n³) |
| n log n + n | O(n log n) |
| 5n + log n + 10 | O(n) |
| 2ⁿ + n⁵ + 1000 | O(2ⁿ) |
Why? For large n, the dominant term overwhelms all others. At n = 1,000: n² = 1,000,000 and n = 1,000. The n term contributes only 0.1% — completely negligible.
Rule 3 — Sequential Code Blocks Add
If your code has multiple sequential sections (one after another, not nested), add their complexities, then apply Rule 2.
def example(array):
# Block 1: O(n)
for x in array:
print(x)
# Block 2: O(n)
for x in array:
print(x * 2)
# Block 3: O(1)
print("Done")
# Total: O(n) + O(n) + O(1) = O(2n + 1) = O(n)Rule 4 — Nested Code Blocks Multiply
If one block executes inside another (nested loops, a loop inside a function called from a loop), multiply their complexities.
Different Variables for Different Inputs
A common mistake is using "n" for everything when there are multiple distinct inputs.
def process_two_arrays(array_a, array_b):
for x in array_a: # len(array_a) iterations = 'a' iterations
print(x)
for y in array_b: # len(array_b) iterations = 'b' iterations
print(y)
# Total: O(a + b) NOT O(n)
# If array_a has 1000 elements and array_b has 1,000,000 elements,
# calling it O(n) would be misleading.def nested_two_arrays(array_a, array_b):
for x in array_a: # a iterations
for y in array_b: # b iterations each
print(x, y)
# Total: O(a × b) NOT O(n²)Use distinct variable names when the inputs are genuinely independent.
Recognizing Common Code Patterns
With practice, you will recognize these patterns instantly without going through the full analysis each time.
Pattern 1 — Single Loop Through Input → O(n)
for i in range(n): # Always O(n)
# O(1) work inside
for element in array: # Always O(n)
# O(1) work insidePattern 2 — Nested Loops, Same Input → O(n²)
for i in range(n): # O(n²)
for j in range(n):
# O(1) work
for i in range(n): # Also O(n²) — n(n-1)/2 pairs
for j in range(i+1, n): # Triangle, but still O(n²)
# O(1) workPattern 3 — Dividing in Half → O(log n)
i = n
while i > 0: # O(log n)
i = i // 2 # Halving
i = 1
while i < n: # O(log n)
i = i * 2 # DoublingPattern 4 — Loop + Dividing in Half → O(n log n)
for i in range(n): # O(n) outer
j = n
while j > 0: # O(log n) inner
j = j // 2
# Total: O(n log n)Pattern 5 — Triple Nested Loops → O(n³)
for i in range(n):
for j in range(n):
for k in range(n):
# O(1)
# Total: O(n³)Pattern 6 — Recursion with Two Branches → O(2ⁿ) (without memoization)
def recursive(n):
if n <= 0: return
recursive(n - 1) # Branch 1
recursive(n - 1) # Branch 2
# Each call creates 2 more calls
# Total calls: 1 + 2 + 4 + 8 + ... + 2ⁿ ≈ 2ⁿ⁺¹ = O(2ⁿ)Pattern 7 — Recursion Halving → O(log n)
def recursive_halving(n):
if n <= 1: return
recursive_halving(n // 2) # Single call, half the input
# Call chain: n → n/2 → n/4 → ... → 1
# Total calls: log₂(n)
# O(log n)Pattern 8 — Recursion Halving + O(n) Work → O(n log n)
Analyzing Algorithms with Loops — Detailed Worked Examples
Example 1 — Find if Array Has a Pair Summing to Target
Analysis:
- When i = 0: inner loop runs n-1 times
- When i = 1: inner loop runs n-2 times
- When i = 2: inner loop runs n-3 times
- ...
- When i = n-2: inner loop runs 1 time
Total = (n-1) + (n-2) + (n-3) + ... + 1 = n(n-1)/2
n(n-1)/2 = n²/2 - n/2 → drop lower term and constant → O(n²)
Optimized version using a hash set:
def has_pair_with_sum_fast(array, target):
seen = set()
for num in array: # n iterations
complement = target - num
if complement in seen: # O(1) lookup
return True
seen.add(num) # O(1) insert
return FalseAnalysis: n × O(1) = O(n) — linear instead of quadratic.
Example 2 — Counting Bits Set to 1
def count_set_bits(n):
"""Count how many bits in the binary representation of n are 1."""
count = 0
while n > 0: # How many times does this loop run?
count += n & 1 # O(1) — check last bit
n >>= 1 # Right shift: n = n / 2
return count
# n in binary has ⌊log₂(n)⌋ + 1 bits
# Each iteration removes one bit (shifts right by 1)
# Total iterations = number of bits = log₂(n)
# Big O: O(log n)Example 3 — Matrix Traversal
Example 4 — Checking All Subarrays
Optimized to O(n) — Kadane's Algorithm:
Amortized Analysis — The Special Case of Python Lists
Not all operations are simple to analyze. Amortized analysis looks at the average cost of an operation over a long sequence, rather than the worst-case cost of a single operation.
Python List append() — Why It Is O(1) Amortized
When you call append() on a Python list, sometimes it takes O(1) (there is space available), and sometimes it takes O(n) (the list must be resized — a new larger array is allocated and all elements are copied).
How can we say append is O(1) amortized?
The Doubling Strategy: When the list runs out of space, Python allocates a new array with twice the current capacity and copies all elements. This copying takes O(n) time, but then the next n appends all take O(1) time.
Over n total appends:
- Copying happens at sizes 1, 2, 4, 8, 16, ..., n/2
- Total copy cost: 1 + 2 + 4 + ... + n/2 = n - 1 (geometric series)
- Total append cost: n (one per append) + (n-1) for copies = 2n - 1
- Average cost per append: (2n - 1) / n ≈ 2 = O(1) amortized
This is why Python's append() is O(1) amortized even though individual appends can be O(n).
Big O for Recursive Algorithms — The Recurrence Relation
For recursive algorithms, we express the time complexity as a recurrence relation — an equation that defines the total cost in terms of the cost of smaller subproblems.
Example — Factorial
def factorial(n):
if n <= 1: # Base case: O(1)
return 1
return n * factorial(n - 1) # 1 multiplication + recursive callRecurrence:
Solving by expansion:
Example — Binary Search
Recurrence:
Solving:
Example — Merge Sort
Recurrence:
Solving using the Recursion Tree method:
| Level 0: 1 problem of size n | n work |
| Level 1: 2 problems of size n/2 | 2 × n/2 = n work |
| Level 2: 4 problems of size n/4 | 4 × n/4 = n work |
| Level 3: 8 problems of size n/8 | 8 × n/8 = n work |
| Level k: 2^k problems of size n/2^k | n work |
| Number of levels | log₂(n) (because at level log₂(n), size = 1) |
| Total work | n work × log₂(n) levels = n log n |
The Master Theorem (Quick Reference)
For recurrences of the form T(n) = a · T(n/b) + f(n) where a ≥ 1, b > 1:
Let c = log_b(a)
| Case | Condition | Result |
|---|---|---|
| Case 1 | f(n) = O(n^(c-ε)) for some ε > 0 | T(n) = Θ(n^c) |
| Case 2 | f(n) = Θ(n^c) | T(n) = Θ(n^c · log n) |
| Case 3 | f(n) = Ω(n^(c+ε)) for some ε > 0 | T(n) = Θ(f(n)) |
Quick examples:
| Merge Sort | T(n) = 2T(n/2) + n |
| f(n) = Θ(n^c) | Case 2 → T(n) = Θ(n log n) ✓ |
| Binary Search | T(n) = T(n/2) + 1 |
| f(n) = Θ(n^c) | Case 2 → T(n) = Θ(log n) ✓ |
| Strassen Matrix | T(n) = 7T(n/2) + n² |
| n^2 grows slower than n^2.807 | Case 1 → T(n) = Θ(n^2.807) ✓ |
Complete Complexity Reference Table
| Algorithm | Best Case | Average Case | Worst Case | Space |
|---|---|---|---|---|
| Searching | ||||
| Linear Search | O(1) | O(n) | O(n) | O(1) |
| Binary Search | O(1) | O(log n) | O(log n) | O(1) |
| Hash Table Lookup | O(1) | O(1) | O(n)* | O(n) |
| Sorting | ||||
| 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) |
| Counting Sort | O(n+k) | O(n+k) | O(n+k) | O(k) |
| Data Structure Operations | ||||
| Array Access | O(1) | O(1) | O(1) | — |
| Array Insertion (end) | O(1) | O(1) | O(1) amortized | — |
| Array Insertion (middle) | O(n) | O(n) | O(n) | — |
| Linked List Insert (head) | O(1) | O(1) | O(1) | — |
| BST Search (balanced) | O(log n) | O(log n) | O(n)† | — |
| Hash Map Put/Get | O(1) | O(1) | O(n)* | — |
| Heap Insert | O(1) | O(log n) | O(log n) | — |
| Heap Extract-Min/Max | O(log n) | O(log n) | O(log n) | — |
*O(n) hash table worst case occurs when all keys hash to the same bucket (hash collision) †O(n) BST worst case occurs when tree is completely unbalanced (essentially a linked list)
Summary
Big O notation is the universal language for describing algorithm efficiency:
- Formal definition: f(n) = O(g(n)) if f(n) ≤ c · g(n) for all n ≥ n₀ (some constants c, n₀)
- Four rules: Drop constants, keep dominant term, add sequential blocks, multiply nested blocks
- Pattern recognition: Learn common patterns (single loop → O(n), nested → O(n²), halving → O(log n))
- Recursion: Express as recurrence relation, solve by expansion, recursion tree, or Master Theorem
- Amortized analysis: For operations whose cost varies, look at the average over a sequence
- Multiple inputs: Use different variables (a, b, m, n) for genuinely independent input sizes
Mastering Big O notation is not optional — it is the lens through which all algorithm design is viewed. Every subsequent topic in this curriculum will be analyzed through this lens.
*Next Lesson: Big Omega Notation — The formal lower bound notation that describes an algorithm's best-case behavior.*
Exam Focus
Revise definitions, diagrams, examples, and short-answer points for Big O Notation — Complete Guide with Formal Definition, Rules, and Examples.
Interview Use
Prepare one clear explanation, one practical example, and one common mistake for this Data Structures & Algorithms topic.
Search Terms
data-structures-algorithms, data structures & algorithms, data, structures, algorithms, algorithm, analysis, big
Related Data Structures & Algorithms Topics