Overview
Time complexity analysis measures how the runtime of an algorithm grows as the input size increases. This analysis focuses on the algorithmic behavior rather than absolute execution time, making it independent of hardware, language, or implementation details.
The primary tool for expressing time complexity is Big O notation, which describes the upper bound of growth rate. An algorithm with O(n) complexity takes time proportional to the input size, while O(n²) grows quadratically. This notation omits constants and lower-order terms, focusing on dominant factors that matter at scale.
Time complexity analysis answers critical questions: Will this algorithm handle 1 million records? How does doubling the input affect runtime? Which of two approaches scales better? These questions directly impact system design decisions, from choosing data structures to architecting distributed systems.
# O(1) - Constant time, independent of input size
def get_first(array)
array[0]
end
# O(n) - Linear time, scales with input size
def sum_all(array)
total = 0
array.each { |num| total += num }
total
end
# => For array of size n, performs n iterations
Computer scientists developed asymptotic analysis in the mid-20th century to compare algorithms mathematically. The notation system provides a common language for discussing algorithmic efficiency across platforms and implementations.
Key Principles
Asymptotic analysis examines algorithm behavior as input size approaches infinity. This approach eliminates noise from constant factors and focuses on growth rate. An algorithm taking 100n + 50 operations has the same asymptotic complexity as one taking n operations—both are O(n).
Three notations describe different bounds. Big O (O) represents the upper bound—the worst-case scenario. Big Omega (Ω) represents the lower bound—the best-case scenario. Big Theta (Θ) represents tight bounds when upper and lower bounds match. Most practical analysis uses Big O because worst-case guarantees matter for reliability.
# Analyzing nested loops
def find_duplicates(array)
duplicates = []
array.each_with_index do |item, i| # Outer loop: n iterations
array.each_with_index do |other, j| # Inner loop: n iterations
if i != j && item == other
duplicates << item unless duplicates.include?(item)
end
end
end
duplicates
end
# Total operations: n * n = O(n²)
Growth rates form a hierarchy. From fastest to slowest: O(1) constant, O(log n) logarithmic, O(n) linear, O(n log n) linearithmic, O(n²) quadratic, O(n³) cubic, O(2^n) exponential, O(n!) factorial. Each jump represents a significant performance difference at scale.
Loop analysis follows straightforward rules. Sequential loops add their complexities: O(n) + O(m) = O(n + m). Nested loops multiply: O(n) * O(m) = O(n * m). When one loop depends on another's variable, complexity often becomes polynomial.
Recursive algorithms require solving recurrence relations. A function calling itself twice on half the input, like merge sort, produces T(n) = 2T(n/2) + O(n), which solves to O(n log n). The Master Theorem provides formulas for common recurrence patterns.
Space complexity measures memory usage growth, analyzed with the same notation. An algorithm can trade time for space or vice versa. Hash tables use O(n) space to achieve O(1) lookup time, while binary search uses O(1) space but requires O(log n) time.
Amortized analysis considers the average cost over a sequence of operations. Dynamic arrays demonstrate this: most insertions are O(1), but occasional resizing is O(n). Averaged over many operations, amortized cost remains O(1) per insertion.
# Demonstrating amortized analysis
class DynamicArray
def initialize
@capacity = 2
@size = 0
@array = Array.new(@capacity)
end
def push(value)
if @size == @capacity
resize # O(n) operation, but rare
end
@array[@size] = value
@size += 1
end
def resize
@capacity *= 2
new_array = Array.new(@capacity)
@size.times { |i| new_array[i] = @array[i] }
@array = new_array
end
end
# Individual push: occasionally O(n), usually O(1)
# Amortized over n pushes: O(1) per push
Ruby Implementation
Ruby's built-in methods have documented complexity characteristics. Array access by index is O(1), but searching with include? is O(n). Hash lookup is O(1) average case, but depends on hash function quality. Understanding these characteristics prevents accidental performance degradation.
# Different approaches, different complexity
names = ["Alice", "Bob", "Charlie", "David"]
# O(n) - Linear search through array
names.include?("Charlie") # => true
# O(1) - Hash lookup
name_set = names.to_set
name_set.include?("Charlie") # => true
Enumerable methods vary in complexity. Methods like map, each, and select are O(n), processing each element once. Methods like sort use O(n log n) comparison sorts. Methods combining operations, like flatten on nested arrays, have complexity based on total elements, not just top-level array size.
# Analyzing method chains
users.select { |u| u.active? } # O(n)
.map { |u| u.name } # O(n)
.sort # O(n log n)
.first(10) # O(1)
# Total: O(n log n) - dominated by sort
Block execution affects analysis. A simple block like { |x| x * 2 } is O(1), but complex blocks increase per-iteration cost. A block calling a method with its own complexity multiplies that into the loop's complexity.
# Hidden complexity in blocks
def process_users(users, projects)
users.map do |user| # O(n) users
{
name: user.name,
project_count: projects.count do |p| # O(m) projects
p.user_id == user.id
end
}
end
end
# Total: O(n * m) - quadratic in combined size
Recursion in Ruby requires stack space consideration. Each recursive call adds a stack frame, limiting practical recursion depth. Tail-call optimization exists in Ruby but isn't enabled by default, making deep recursion risky.
# Recursive complexity analysis
def factorial(n)
return 1 if n <= 1
n * factorial(n - 1)
end
# Time: O(n) - n recursive calls
# Space: O(n) - n stack frames
# Iterative alternative
def factorial_iterative(n)
result = 1
(2..n).each { |i| result *= i }
result
end
# Time: O(n) - same time complexity
# Space: O(1) - constant space
Ruby's garbage collector adds real-world performance considerations not captured by asymptotic analysis. Creating many temporary objects can trigger collection, adding overhead. This matters in practice despite theoretical complexity remaining unchanged.
Lazy enumerables defer computation, affecting when complexity manifests. array.lazy.map { ... }.first(5) evaluates only five elements, not the entire array. This transforms O(n) operations into O(1) when taking constant-size results.
# Lazy evaluation changes practical complexity
huge_array = (1..1_000_000).to_a
# Eager: processes all 1M elements
huge_array.map { |n| n * 2 }.first(5) # O(n) for full map
# Lazy: processes only 5 elements
huge_array.lazy.map { |n| n * 2 }.first(5) # O(1) - constant work
Practical Examples
Linear search demonstrates O(n) complexity through examining each element until finding the target or exhausting the collection. Worst case requires checking every element.
def linear_search(array, target)
array.each_with_index do |element, index|
return index if element == target
end
nil
end
# Best case: O(1) - target at first position
# Worst case: O(n) - target at end or absent
# Average case: O(n) - expected to check n/2 elements
Binary search achieves O(log n) by halving the search space each iteration. Requires sorted input. Each comparison eliminates half the remaining elements.
def binary_search(sorted_array, target)
left = 0
right = sorted_array.length - 1
while left <= right
mid = (left + right) / 2
if sorted_array[mid] == target
return mid
elsif sorted_array[mid] < target
left = mid + 1
else
right = mid - 1
end
end
nil
end
# Each iteration: halves search space
# After k iterations: n / 2^k elements remain
# Terminates when n / 2^k = 1
# Solving: 2^k = n means k = log₂(n)
# Complexity: O(log n)
Bubble sort illustrates O(n²) quadratic complexity through nested iteration. Outer loop runs n times, inner loop runs up to n times per outer iteration.
def bubble_sort(array)
n = array.length
(n - 1).times do |i| # Outer: n-1 iterations
(n - i - 1).times do |j| # Inner: decreasing iterations
if array[j] > array[j + 1]
array[j], array[j + 1] = array[j + 1], array[j]
end
end
end
array
end
# Total comparisons: (n-1) + (n-2) + ... + 1 = n(n-1)/2
# Simplified: O(n²)
Merge sort demonstrates O(n log n) divide-and-conquer efficiency. Dividing takes O(log n) levels, merging takes O(n) per level.
def merge_sort(array)
return array if array.length <= 1
mid = array.length / 2
left = merge_sort(array[0...mid]) # Divide: log n levels
right = merge_sort(array[mid..-1])
merge(left, right) # Conquer: O(n) per level
end
def merge(left, right)
result = []
until left.empty? || right.empty?
if left.first <= right.first
result << left.shift
else
result << right.shift
end
end
result + left + right
end
# Recurrence: T(n) = 2T(n/2) + O(n)
# Solution: O(n log n)
Hash table operations demonstrate constant average-case complexity with linear worst-case scenarios. Good hash functions distribute keys evenly, minimizing collisions.
# Using hash for O(1) lookups
def find_pair_sum(array, target)
seen = {}
array.each do |num| # O(n) iterations
complement = target - num
return true if seen[complement] # O(1) hash lookup
seen[num] = true # O(1) hash insertion
end
false
end
# Total: O(n) with O(n) space
# Without hash: O(n²) with nested loops
Fibonacci calculation shows exponential recursion inefficiency versus linear dynamic programming.
# Naive recursion: O(2^n)
def fib_recursive(n)
return n if n <= 1
fib_recursive(n - 1) + fib_recursive(n - 2)
end
# Recalculates same values exponentially many times
# Dynamic programming: O(n)
def fib_dp(n)
return n if n <= 1
prev, curr = 0, 1
(2..n).each do
prev, curr = curr, prev + curr
end
curr
end
# Each value calculated exactly once
Common Patterns
Constant time O(1) operations execute in fixed time regardless of input size. Array indexing, hash table lookups, variable assignment, arithmetic operations all demonstrate constant complexity.
# All O(1) operations
array[5] # Direct index access
hash[key] # Hash lookup
x = y + z # Arithmetic
array.length # Stored value access
Logarithmic time O(log n) appears in algorithms repeatedly halving the problem space. Binary search, balanced tree operations, and certain divide-and-conquer algorithms exhibit logarithmic growth.
# Finding position in sorted array: O(log n)
def find_insert_position(sorted_array, value)
left, right = 0, sorted_array.length
while left < right
mid = (left + right) / 2
if sorted_array[mid] < value
left = mid + 1
else
right = mid
end
end
left
end
Linear time O(n) processes each input element constant times. Single loops, linear search, and many array operations follow this pattern.
# Finding maximum: O(n)
def find_max(array)
max = array.first
array.each { |num| max = num if num > max }
max
end
# Filtering: O(n)
def filter_even(array)
array.select { |num| num.even? }
end
Linearithmic time O(n log n) characterizes efficient sorting algorithms. Merge sort, heap sort, and quicksort average case all achieve this complexity.
# Quicksort average case: O(n log n)
def quicksort(array)
return array if array.length <= 1
pivot = array[array.length / 2]
left = array.select { |x| x < pivot }
middle = array.select { |x| x == pivot }
right = array.select { |x| x > pivot }
quicksort(left) + middle + quicksort(right)
end
Quadratic time O(n²) results from nested iteration over the same input. Double loops, naive duplicate detection, and bubble sort demonstrate quadratic complexity.
# Checking all pairs: O(n²)
def has_duplicate(array)
array.each_with_index do |item, i|
array.each_with_index do |other, j|
return true if i != j && item == other
end
end
false
end
Exponential time O(2^n) grows extremely rapidly, often from exploring all subsets or combinations. Many brute-force solutions and naive recursive algorithms exhibit exponential complexity.
# Generating all subsets: O(2^n)
def all_subsets(array)
return [[]] if array.empty?
first = array[0]
rest_subsets = all_subsets(array[1..-1])
with_first = rest_subsets.map { |subset| [first] + subset }
rest_subsets + with_first
end
# Each element: include or exclude = 2^n subsets
Performance Considerations
Asymptotic analysis ignores constants, but constants matter in practice. An O(n) algorithm with high constant overhead may perform worse than O(n log n) on realistic input sizes. Profiling reveals where theoretical analysis diverges from observed performance.
Input size determines which complexity class dominates. For small n, O(n²) may outperform O(n log n) due to lower overhead. Sorting 10 elements doesn't benefit from sophisticated algorithms. Sorting 1 million elements makes algorithmic choice critical.
# Simple insertion sort outperforms quicksort on tiny arrays
def hybrid_sort(array)
if array.length < 10
insertion_sort(array) # O(n²) but low overhead
else
quicksort(array) # O(n log n) with higher overhead
end
end
Space-time tradeoffs frequently arise. Memoization exchanges O(n) memory for exponential time savings. Hash tables use O(n) space for O(1) lookup versus O(n) search in arrays. Compression trades CPU time for reduced memory and bandwidth.
# Memoized fibonacci: trades space for time
def fib_memo(n, memo = {})
return n if n <= 1
return memo[n] if memo[n]
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
end
# Time: O(n) instead of O(2^n)
# Space: O(n) for memoization hash
Cache behavior affects real-world performance beyond asymptotic analysis. Algorithms accessing memory sequentially benefit from CPU cache, while random access patterns suffer cache misses. This makes array iteration faster than linked list traversal despite identical O(n) complexity.
Real-world input characteristics influence practical performance. Sorted or nearly-sorted input makes insertion sort O(n) instead of O(n²). Random pivot selection in quicksort avoids O(n²) worst case on sorted input. Knowing input patterns guides algorithm selection.
Database queries demonstrate how complexity analysis scales to distributed systems. A query joining two tables represents O(n * m) complexity. Adding indexes reduces search from O(n) to O(log n), dramatically improving performance at large scale.
# Database-backed duplicate detection
class User
def self.find_duplicates_naive
# Fetches all users: O(n) network + memory
users = User.all.to_a
users.select do |user|
# For each user, queries database: O(n) queries
User.where(email: user.email).count > 1
end
end
# Total: O(n²) database queries - unacceptable
def self.find_duplicates_optimized
# Single query with GROUP BY: O(n log n) in database
User.select(:email)
.group(:email)
.having('COUNT(*) > 1')
.pluck(:email)
end
# Total: O(n log n) - one database operation
end
Common Pitfalls
Ignoring hidden complexity in library methods leads to unexpectedly slow code. Methods appearing constant-time may perform linear operations internally. Ruby's Array#uniq is O(n), String#reverse is O(n), and include? on arrays is O(n).
# Looks innocent, actually O(n²)
def remove_duplicates_bad(array)
result = []
array.each do |item|
result << item unless result.include?(item) # O(n) check
end
result
end
# Each iteration: O(n) check, n iterations = O(n²)
# Correct approach: O(n)
def remove_duplicates_good(array)
array.uniq # Uses hash internally
end
Analyzing only the dominant term without considering real-world input sizes causes poor decisions. An O(n²) algorithm may outperform O(n log n) for n < 100. Premature optimization based solely on asymptotic analysis wastes effort.
Confusing best, average, and worst cases leads to unrealistic expectations. Quicksort averages O(n log n) but degrades to O(n²) on pathological input. Hash tables average O(1) but worst-case O(n) with poor hash functions or adversarial input.
Overlooking space complexity while optimizing time complexity creates memory problems. Recursive solutions may crash from stack overflow despite good time complexity. Building large intermediate data structures may exhaust memory.
# Time-efficient but space-wasteful
def generate_primes_wasteful(n)
# Creates array of n booleans: O(n) space
sieve = Array.new(n + 1, true)
sieve[0] = sieve[1] = false
(2..Math.sqrt(n)).each do |i|
if sieve[i]
(i*i..n).step(i) { |j| sieve[j] = false }
end
end
sieve.each_with_index.select { |prime, _| prime }.map { |_, i| i }
end
# Time: O(n log log n), Space: O(n)
# For n = 1 billion, requires ~1GB memory
Assuming Big O applies to small inputs causes optimization in the wrong places. Asymptotic analysis describes behavior as n approaches infinity. For bounded input sizes, different factors dominate.
Forgetting that complexity analysis applies per operation, not per program. A loop running 1000 times with O(1) body is O(1000) = O(1) for that specific program, but if the loop count depends on input n, complexity becomes O(n).
Misunderstanding amortized complexity leads to incorrect worst-case assumptions. Dynamic array insertion is O(1) amortized but occasionally O(n). Applications requiring guaranteed O(1) per operation cannot use dynamic arrays.
Reference
Complexity Classes
| Notation | Name | Example Operations | Growth Rate |
|---|---|---|---|
| O(1) | Constant | Array index, hash lookup, variable assignment | Unchanged |
| O(log n) | Logarithmic | Binary search, balanced tree operations | Doubles when n squares |
| O(n) | Linear | Array iteration, linear search | Doubles when n doubles |
| O(n log n) | Linearithmic | Merge sort, heap sort, efficient sorting | Slightly more than linear |
| O(n²) | Quadratic | Nested loops, bubble sort, selection sort | Quadruples when n doubles |
| O(n³) | Cubic | Triple nested loops, naive matrix multiplication | 8x when n doubles |
| O(2^n) | Exponential | Recursive fibonacci, subset generation | Squares when n increases by 1 |
| O(n!) | Factorial | Permutation generation, traveling salesman brute force | Extremely rapid growth |
Common Data Structure Operations
| Data Structure | Access | Search | Insert | Delete | Space |
|---|---|---|---|---|---|
| Array | O(1) | O(n) | O(n) | O(n) | O(n) |
| Hash Table | N/A | O(1) avg | O(1) avg | O(1) avg | O(n) |
| Binary Search Tree | O(log n) avg | O(log n) avg | O(log n) avg | O(log n) avg | O(n) |
| Balanced Tree | O(log n) | O(log n) | O(log n) | O(log n) | O(n) |
| Stack | O(n) | O(n) | O(1) | O(1) | O(n) |
| Queue | O(n) | O(n) | O(1) | O(1) | O(n) |
| Linked List | O(n) | O(n) | O(1) | O(1) | O(n) |
Common Algorithm Complexities
| Algorithm | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Binary Search | O(log n) | O(1) | Requires sorted input |
| Linear Search | O(n) | O(1) | Works on unsorted data |
| Bubble Sort | O(n²) | O(1) | Simple but inefficient |
| Selection Sort | O(n²) | O(1) | Inefficient, few swaps |
| Insertion Sort | O(n²) avg, O(n) best | O(1) | Efficient for small or nearly sorted |
| Merge Sort | O(n log n) | O(n) | Stable, predictable |
| Quick Sort | O(n log n) avg, O(n²) worst | O(log n) | Fast in practice |
| Heap Sort | O(n log n) | O(1) | In-place, not stable |
| Breadth-First Search | O(V + E) | O(V) | Graph traversal |
| Depth-First Search | O(V + E) | O(V) | Graph traversal |
| Dijkstra's Algorithm | O((V + E) log V) | O(V) | Shortest path |
Analysis Checklist
| Step | Question | Action |
|---|---|---|
| 1 | Identify input size variable | Define what n represents |
| 2 | Count simple operations | Each statement: O(1) |
| 3 | Analyze sequential blocks | Add complexities |
| 4 | Analyze loops | Multiply iterations by body complexity |
| 5 | Analyze nested loops | Multiply loop complexities |
| 6 | Analyze recursion | Solve recurrence relation |
| 7 | Identify dominant term | Keep highest-order term |
| 8 | Drop constants and coefficients | Simplify to Big O notation |
| 9 | Consider best, average, worst | Document all three cases |
| 10 | Verify with examples | Test analysis on sample inputs |
Ruby Method Complexity Reference
| Method | Complexity | Notes |
|---|---|---|
| Array#[] | O(1) | Direct index access |
| Array#push | O(1) amortized | May resize array |
| Array#pop | O(1) | Removes last element |
| Array#shift | O(n) | Removes first, shifts remaining |
| Array#unshift | O(n) | Adds at start, shifts remaining |
| Array#include? | O(n) | Linear search |
| Array#sort | O(n log n) | Comparison sort |
| Array#reverse | O(n) | Creates new array |
| Hash#[] | O(1) avg | Depends on hash quality |
| Hash#[]= | O(1) avg | Insertion or update |
| Hash#delete | O(1) avg | Remove key-value pair |
| Set#include? | O(1) avg | Hash-based membership |
| Set#add | O(1) avg | Hash-based insertion |
| String#[] | O(1) | Character access |
| String#include? | O(n*m) | Substring search |
| String#reverse | O(n) | Creates new string |