Time Complexity — Big O and Master Theorem
Time Complexity
Time complexity describes how the running time of an algorithm grows as the input size grows. It does not measure exact seconds on a particular computer. Instead, it estimates how many basic operations the algorithm performs.
This helps us compare algorithms in a machine-independent way. A solution that looks fast for small input may become very slow for large input, so time complexity is one of the most important tools in algorithm analysis.
Why Time Complexity Matters
Two algorithms may solve the same problem correctly, but their performance can be very different when the input becomes large.
- Better scalability: Efficient algorithms continue to perform well as data grows.
- Lower cost: Faster algorithms reduce CPU time and server usage.
- Improved user experience: Good time complexity leads to faster applications.
- Smarter problem solving: It helps us choose the right approach before coding.
What Does Time Complexity Measure?
Time complexity usually measures the number of basic operations performed by an algorithm, such as:
- comparisons
- assignments
- arithmetic operations
- loop iterations
- recursive calls
We usually express time complexity using Big O notation, which focuses on the dominant growth term and ignores minor constants.
How to Calculate Time Complexity
The following rules are commonly used while analyzing code:
| Code Pattern | Typical Complexity | Reason |
|---|---|---|
| Simple statement | O(1) | Takes constant time |
| Single loop running n times | O(n) | One pass over input |
| Two nested loops of size n | O(n^2) | n multiplied by n |
| Loop that halves the problem each step | O(log n) | Input size shrinks quickly |
| Sequential independent blocks | Add them, then keep dominant term | O(n) + O(n^2) becomes O(n^2) |
| Recursive algorithm | Use recurrence relation | Analyze how work repeats |
Common Time Complexity Classes
1. O(1) - Constant Time
The running time does not depend on the size of the input. The algorithm performs a fixed number of operations.
def get_first(arr):
return arr[0]
def is_even(n):
return n % 2 == 0
def swap(a, b):
return b, a
2. O(log n) - Logarithmic Time
The running time grows slowly because the problem size is reduced by a constant factor in each step. Binary Search is the classic example.
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
if arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
# Each step cuts the search space in half.
3. O(n) - Linear Time
The algorithm processes each element once, so running time grows directly with input size.
def find_max(arr):
max_val = arr[0]
for num in arr:
if num > max_val:
max_val = num
return max_val
4. O(n log n) - Linearithmic Time
This often appears in efficient divide-and-conquer algorithms such as Merge Sort and Heap Sort. The problem is divided in logarithmic levels, and each level does linear work.
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)
# Recurrence: T(n) = 2T(n/2) + O(n)
# Result: O(n log n)
5. O(n^2) - Quadratic Time
Quadratic time usually appears when one loop is nested inside another loop over the same input.
def print_pairs(arr):
for i in range(len(arr)):
for j in range(len(arr)):
print(arr[i], arr[j])
# Number of pair checks grows roughly as n^2.
6. O(2^n) - Exponential Time
Exponential time appears in algorithms where each step creates multiple recursive branches. These algorithms become impractical very quickly.
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Creates many repeated recursive calls.
# Time complexity is exponential.
Best Case, Average Case, and Worst Case
An algorithm may not behave the same way for every input. That is why time complexity is often discussed in three forms:
| Case | Meaning | Example |
|---|---|---|
| Best case | Minimum time taken for some input | Linear search finds the item at the first position |
| Average case | Expected time for typical input | Linear search usually checks several elements |
| Worst case | Maximum time taken for input of size n | Linear search checks the whole array |
| Algorithm | Best Case | Average Case | Worst Case |
|---|---|---|---|
| Linear Search | O(1) | O(n) | O(n) |
| Binary Search | O(1) | O(log n) | O(log n) |
| Bubble Sort | O(n) | O(n^2) | O(n^2) |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) |
| Quick Sort | O(n log n) | O(n log n) | O(n^2) |
Analyzing Loops
Loops are one of the easiest places to estimate complexity.
# O(n)
for i in range(n):
print(i)
# O(n^2)
for i in range(n):
for j in range(n):
print(i, j)
# O(log n)
i = 1
while i < n:
print(i)
i = i * 2
# O(n)
for i in range(n):
print(i)
for j in range(n):
print(j)
Important idea:
- Sequential loops usually add.
- Nested loops usually multiply.
- Loops that repeatedly divide or double a value are often logarithmic.
Analyzing Recursion
Recursive algorithms are often analyzed using a recurrence relation. A recurrence shows how the work for size n depends on smaller inputs.
| Recurrence | Example | Result |
|---|---|---|
| T(n) = T(n - 1) + O(1) | Factorial | O(n) |
| T(n) = T(n / 2) + O(1) | Binary Search | O(log n) |
| T(n) = 2T(n / 2) + O(n) | Merge Sort | O(n log n) |
| T(n) = T(n - 1) + O(n) | Selection Sort style sum | O(n^2) |
| T(n) = 2T(n - 1) + O(1) | Naive Fibonacci | Exponential |
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
# T(n) = T(n - 1) + O(1) -> O(n)
def binary_search_recursive(arr, target, left, right):
if left > right:
return -1
mid = (left + right) // 2
if arr[mid] == target:
return mid
if arr[mid] < target:
return binary_search_recursive(arr, target, mid + 1, right)
return binary_search_recursive(arr, target, left, mid - 1)
# T(n) = T(n / 2) + O(1) -> O(log n)
Amortized Time Complexity
Amortized analysis studies the average cost of a sequence of operations when most operations are cheap and a few are expensive.
Most insertions take O(1). Occasionally the array must be resized and copied, which takes O(n). But over many insertions, the average cost per insertion remains O(1).
Master Theorem
The Master Theorem is a shortcut for solving recurrences of the form T(n) = aT(n / b) + f(n).
| Case | Condition | Result |
|---|---|---|
| Case 1 | f(n) is smaller than n^(log_b a) | T(n) = Theta(n^(log_b a)) |
| Case 2 | f(n) has the same order as n^(log_b a) | T(n) = Theta(n^(log_b a) log n) |
| Case 3 | f(n) is larger than n^(log_b a) | T(n) = Theta(f(n)) |
Example: Merge Sort has T(n) = 2T(n / 2) + O(n). Here, a = 2 and b = 2. Since n^(log_b a) = n, this falls under Case 2, so the result is Theta(n log n).
Real-World Impact of Time Complexity
| Complexity | n = 10 | n = 1,000 | n = 100,000 | Practical Meaning |
|---|---|---|---|---|
| O(1) | 1 step | 1 step | 1 step | Very fast |
| O(log n) | About 3 | About 10 | About 17 | Excellent scaling |
| O(n) | 10 | 1,000 | 100,000 | Usually acceptable |
| O(n log n) | About 30 | About 10,000 | About 1.7 million | Efficient for sorting and divide-and-conquer |
| O(n^2) | 100 | 1 million | 10 billion | Too slow for large input |
| O(2^n) | 1,024 | Infeasible | Infeasible | Useful only for tiny inputs |
Practical Optimization Ideas
- Use hashing to replace nested loops when possible.
- Use binary search on sorted data instead of linear search.
- Use dynamic programming to avoid repeated recursive work.
- Prefer single-pass logic over sorting when sorting is unnecessary.
- Choose divide-and-conquer algorithms for large data where appropriate.
# O(n^2)
def two_sum_slow(arr, target):
for i in range(len(arr)):
for j in range(i + 1, len(arr)):
if arr[i] + arr[j] == target:
return [i, j]
return None
# O(n)
def two_sum_fast(arr, target):
seen = {}
for i, num in enumerate(arr):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return None
Common Mistakes in Time Complexity Analysis
- Counting actual seconds instead of counting growth in operations.
- Ignoring the effect of nested loops.
- Forgetting to simplify expressions like O(3n + 10) to O(n).
- Assuming recursive code is always slow without analyzing the recurrence.
- Focusing only on best case and ignoring worst-case behavior.
Key Takeaways
- Time complexity describes how running time grows with input size.
- Big O is commonly used to express worst-case growth.
- Loops, nested loops, logarithmic reduction, and recursion are the main patterns to analyze.
- O(1), O(log n), and O(n) scale well, while O(n^2) and O(2^n) become expensive quickly.
- Understanding time complexity helps you choose practical and scalable algorithms.
- Time complexity measures how the running time of an algorithm grows relative to input size n.
- Single loops are usually O(n), nested loops often become O(n^2), and repeated halving usually leads to O(log n).
- Recursion is often analyzed with recurrence relations such as T(n) = T(n / 2) + O(1) or T(n) = 2T(n / 2) + O(n).
- Best case, average case, and worst case can differ significantly for the same algorithm.
- Lower complexity classes like O(log n), O(n), and O(n log n) are usually preferred for large inputs.
- Time complexity helps compare algorithm efficiency without depending on hardware or language.
Level Up Your Daa Skills
Master Daa with these hand-picked resources