
Time and Space Complexity Refresher
/ 19 min read
Table of Contents
What is Complexity Analysis?
Complexity Analysis is the process of determining how the runtime (time complexity) and memory usage (space complexity) of an algorithm grow as the input size increases. It helps us compare algorithms and choose the most efficient one for our needs.
Why is it Important?
- Performance Prediction: Understand how algorithms scale with data size
- Algorithm Comparison: Choose the best algorithm for your constraints
- Resource Planning: Estimate computational and memory requirements
- Optimization: Identify bottlenecks and improve efficiency
- Interview Success: Essential for technical interviews
Big O Notation
Big O notation describes the upper bound of an algorithm’s growth rate. It represents the worst-case scenario.
Common Big O Complexities (Best to Worst)
| Notation | Name | Description | Example |
|---|---|---|---|
| O(1) | Constant | Same time regardless of input size | Array access |
| O(log n) | Logarithmic | Divides problem in half each step | Binary search |
| O(n) | Linear | Time grows linearly with input | Linear search |
| O(n log n) | Linearithmic | Efficient sorting algorithms | Merge sort |
| O(n²) | Quadratic | Nested loops over input | Bubble sort |
| O(n³) | Cubic | Triple nested loops | Naive matrix multiplication |
| O(2ⁿ) | Exponential | Doubles with each addition | Recursive Fibonacci |
| O(n!) | Factorial | Extremely slow growth | Traveling salesman (brute force) |
Visual Growth Comparison
Operations (relative growth)^| O(n!)| /| O(2ⁿ)| /| O(n³)| /| O(n²)| /| O(n log n)|/| O(n)| \| \| O(log n)| \| \______________________________> Input size (n)| O(1)Time Complexity Analysis
O(1) - Constant Time
Definition: Algorithm takes the same amount of time regardless of input size.
def get_first_element(arr): """O(1) - Always takes the same time""" if len(arr) > 0: return arr[0] return None
def hash_table_lookup(hash_table, key): """O(1) - Hash table access""" return hash_table.get(key)
def add_to_beginning(linked_list, value): """O(1) - Adding to front of linked list""" new_node = Node(value) new_node.next = linked_list.head linked_list.head = new_nodeCharacteristics:
- Does not depend on input size
- Always performs the same number of operations
- Most efficient possible complexity
O(log n) - Logarithmic Time
Definition: Algorithm divides the problem size by a constant factor each step.
def binary_search(arr, target): """O(log n) - Divides search space in half each iteration""" 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
def find_height(binary_tree_node): """O(log n) for balanced tree""" if not binary_tree_node: return 0 return 1 + max(find_height(binary_tree_node.left), find_height(binary_tree_node.right))Characteristics:
- Problem size reduces by half (or constant factor) each step
- Very efficient for large datasets
- Common in divide-and-conquer algorithms
O(n) - Linear Time
Definition: Algorithm processes each element once.
def linear_search(arr, target): """O(n) - May need to check every element""" for i, element in enumerate(arr): if element == target: return i return -1
def find_max(arr): """O(n) - Must check every element""" if not arr: return None
max_val = arr[0] for num in arr[1:]: if num > max_val: max_val = num return max_val
def count_occurrences(arr, target): """O(n) - Count how many times target appears""" count = 0 for element in arr: if element == target: count += 1 return countCharacteristics:
- Processing time increases linearly with input size
- Each element is typically processed once
- Often unavoidable when you need to examine all data
O(n log n) - Linearithmic Time
Definition: Combination of linear and logarithmic complexity, common in efficient sorting algorithms.
def merge_sort(arr): """O(n log n) - Divide and conquer sorting""" if len(arr) <= 1: return arr
mid = len(arr) // 2 left = merge_sort(arr[:mid]) # O(log n) levels right = merge_sort(arr[mid:]) # O(log n) levels
return merge(left, right) # O(n) work per level
def merge(left, right): """O(n) - Merge two sorted arrays""" result = [] i = j = 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
def heap_sort(arr): """O(n log n) - Build heap then extract elements""" import heapq heapq.heapify(arr) # O(n) sorted_arr = [] while arr: sorted_arr.append(heapq.heappop(arr)) # O(log n) × n times return sorted_arrCharacteristics:
- Optimal complexity for comparison-based sorting
- Balances divide-and-conquer efficiency with linear work
- Examples: Merge sort, Heap sort, Quick sort (average case)
O(n²) - Quadratic Time
Definition: Algorithm has nested loops over the input.
def bubble_sort(arr): """O(n²) - Nested loops over array""" n = len(arr) for i in range(n): # Outer loop: n iterations for j in range(0, n - i - 1): # Inner loop: up to n iterations if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] return arr
def find_all_pairs(arr): """O(n²) - Generate all possible pairs""" pairs = [] for i in range(len(arr)): # n iterations for j in range(i + 1, len(arr)): # up to n iterations pairs.append((arr[i], arr[j])) return pairs
def naive_string_matching(text, pattern): """O(nm) where n=len(text), m=len(pattern)""" matches = [] for i in range(len(text) - len(pattern) + 1): if text[i:i+len(pattern)] == pattern: matches.append(i) return matchesCharacteristics:
- Performance degrades quickly with input size
- Often involves comparing every element with every other element
- Should be optimized when possible for large datasets
O(2ⁿ) - Exponential Time
Definition: Algorithm explores all possible solutions.
def fibonacci_recursive(n): """O(2ⁿ) - Naive recursive approach""" if n <= 1: return n return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
def generate_all_subsets(arr): """O(2ⁿ) - Generate all possible subsets""" def backtrack(start, current_subset): result.append(current_subset[:]) # Add current subset
for i in range(start, len(arr)): current_subset.append(arr[i]) # Include element backtrack(i + 1, current_subset) current_subset.pop() # Exclude element
result = [] backtrack(0, []) return result
def solve_n_queens(n): """O(n!) but often analyzed as exponential""" def is_safe(board, row, col): # Check column for i in range(row): if board[i][col] == 1: return False
# Check diagonals for i, j in zip(range(row-1, -1, -1), range(col-1, -1, -1)): if board[i][j] == 1: return False
for i, j in zip(range(row-1, -1, -1), range(col+1, n)): if board[i][j] == 1: return False
return True
def solve(board, row): if row >= n: return True
for col in range(n): if is_safe(board, row, col): board[row][col] = 1 if solve(board, row + 1): return True board[row][col] = 0
return False
board = [[0 for _ in range(n)] for _ in range(n)] solve(board, 0) return boardCharacteristics:
- Extremely slow growth
- Often seen in brute-force algorithms
- Usually needs optimization for practical use
Space Complexity Analysis
Space Complexity measures how much additional memory an algorithm uses relative to the input size.
Types of Space Usage
- Input Space: Memory used to store the input
- Auxiliary Space: Extra memory used by the algorithm
- Output Space: Memory used to store the output
Note: Space complexity typically refers to auxiliary space only.
O(1) - Constant Space
def reverse_array_in_place(arr): """O(1) space - Only uses a few variables""" left = 0 right = len(arr) - 1
while left < right: arr[left], arr[right] = arr[right], arr[left] left += 1 right -= 1
return arr
def find_max_iterative(arr): """O(1) space - Only stores max value""" if not arr: return None
max_val = arr[0] for num in arr: if num > max_val: max_val = num return max_valO(log n) - Logarithmic Space
def binary_search_recursive(arr, target, left=0, right=None): """O(log n) space - Recursive call stack""" if right is None: right = len(arr) - 1
if left > right: return -1
mid = (left + right) // 2 if arr[mid] == target: return mid elif arr[mid] < target: return binary_search_recursive(arr, target, mid + 1, right) else: return binary_search_recursive(arr, target, left, mid - 1)
def calculate_height(node): """O(log n) space for balanced tree - call stack""" if not node: return 0 return 1 + max(calculate_height(node.left), calculate_height(node.right))O(n) - Linear Space
def merge_sort(arr): """O(n) space - Creates new arrays for merging""" if len(arr) <= 1: return arr
mid = len(arr) // 2 left = merge_sort(arr[:mid]) # O(n) space for new arrays right = merge_sort(arr[mid:]) # O(n) space for new arrays
return merge(left, right)
def fibonacci_dp(n): """O(n) space - Stores all intermediate results""" if n <= 1: return n
dp = [0] * (n + 1) dp[1] = 1
for i in range(2, n + 1): dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
def level_order_traversal(root): """O(n) space - Queue can hold up to n/2 nodes""" if not root: return []
queue = [root] result = []
while queue: node = queue.pop(0) result.append(node.val)
if node.left: queue.append(node.left) if node.right: queue.append(node.right)
return resultO(n²) - Quadratic Space
def floyd_warshall(graph): """O(n²) space - 2D distance matrix""" n = len(graph) dist = [[float('inf')] * n for _ in range(n)]
# Initialize distances for i in range(n): for j in range(n): if i == j: dist[i][j] = 0 elif graph[i][j] != 0: dist[i][j] = graph[i][j]
# Floyd-Warshall algorithm for k in range(n): for i in range(n): for j in range(n): dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
return dist
def generate_multiplication_table(n): """O(n²) space - 2D table""" table = [] for i in range(1, n + 1): row = [] for j in range(1, n + 1): row.append(i * j) table.append(row) return tableCommon Data Structure Complexities
Array Operations
| Operation | Time Complexity | Space Complexity |
|---|---|---|
| Access | O(1) | O(1) |
| Search | O(n) | O(1) |
| Insertion (end) | O(1) amortized | O(1) |
| Insertion (beginning) | O(n) | O(1) |
| Deletion (end) | O(1) | O(1) |
| Deletion (beginning) | O(n) | O(1) |
# Array operations examplesdef array_operations(): arr = [1, 2, 3, 4, 5]
# O(1) access element = arr[2]
# O(n) search index = arr.index(3) if 3 in arr else -1
# O(1) append arr.append(6)
# O(n) insert at beginning arr.insert(0, 0)
# O(1) pop from end arr.pop()
# O(n) pop from beginning arr.pop(0)Linked List Operations
| Operation | Time Complexity | Space Complexity |
|---|---|---|
| Access | O(n) | O(1) |
| Search | O(n) | O(1) |
| Insertion (beginning) | O(1) | O(1) |
| Insertion (end) | O(n) without tail, O(1) with tail | O(1) |
| Deletion (beginning) | O(1) | O(1) |
| Deletion (end) | O(n) | O(1) |
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next
class LinkedList: def __init__(self): self.head = None
def insert_at_beginning(self, val): """O(1) time, O(1) space""" new_node = ListNode(val) new_node.next = self.head self.head = new_node
def search(self, val): """O(n) time, O(1) space""" current = self.head while current: if current.val == val: return current current = current.next return None
def delete(self, val): """O(n) time, O(1) space""" if not self.head: return
if self.head.val == val: self.head = self.head.next return
current = self.head while current.next: if current.next.val == val: current.next = current.next.next return current = current.nextHash Table Operations
| Operation | Average Time | Worst Time | Space Complexity |
|---|---|---|---|
| Access | O(1) | O(n) | O(n) |
| Search | O(1) | O(n) | O(1) |
| Insertion | O(1) | O(n) | O(1) |
| Deletion | O(1) | O(n) | O(1) |
class HashTable: def __init__(self, size=10): self.size = size self.table = [[] for _ in range(size)]
def _hash(self, key): """Simple hash function""" return hash(key) % self.size
def insert(self, key, value): """O(1) average, O(n) worst case""" index = self._hash(key) bucket = self.table[index]
for i, (k, v) in enumerate(bucket): if k == key: bucket[i] = (key, value) return
bucket.append((key, value))
def get(self, key): """O(1) average, O(n) worst case""" index = self._hash(key) bucket = self.table[index]
for k, v in bucket: if k == key: return v
raise KeyError(key)Tree Operations
Binary Search Tree
| Operation | Average Time | Worst Time | Space Complexity |
|---|---|---|---|
| Search | O(log n) | O(n) | O(log n) |
| Insertion | O(log n) | O(n) | O(log n) |
| Deletion | O(log n) | O(n) | O(log n) |
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right
class BST: def __init__(self): self.root = None
def insert(self, val): """O(log n) average, O(n) worst case time; O(log n) space""" def insert_recursive(node, val): if not node: return TreeNode(val)
if val < node.val: node.left = insert_recursive(node.left, val) else: node.right = insert_recursive(node.right, val)
return node
self.root = insert_recursive(self.root, val)
def search(self, val): """O(log n) average, O(n) worst case time; O(log n) space""" def search_recursive(node, val): if not node or node.val == val: return node
if val < node.val: return search_recursive(node.left, val) else: return search_recursive(node.right, val)
return search_recursive(self.root, val)Algorithm Complexity Examples
Sorting Algorithms
| Algorithm | Best Time | Average Time | Worst Time | Space |
|---|---|---|---|---|
| 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) |
def bubble_sort(arr): """ Time: O(n²) average and worst, O(n) best Space: O(1) """ n = len(arr) for i in range(n): swapped = False for j in range(0, n - i - 1): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] swapped = True
if not swapped: # Early termination for best case break
return arr
def merge_sort(arr): """ Time: O(n log n) all cases Space: O(n) """ 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 quick_sort(arr): """ Time: O(n log n) average, O(n²) worst Space: O(log n) average, O(n) worst """ if len(arr) <= 1: return arr
pivot = arr[len(arr) // 2] left = [x for x in arr if x < pivot] middle = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot]
return quick_sort(left) + middle + quick_sort(right)Search Algorithms
def linear_search(arr, target): """ Time: O(n) Space: O(1) """ for i, element in enumerate(arr): if element == target: return i return -1
def binary_search(arr, target): """ Time: O(log n) Space: O(1) iterative, O(log n) recursive """ 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
def depth_first_search(graph, start, target): """ Time: O(V + E) where V = vertices, E = edges Space: O(V) for recursion stack """ def dfs(node, visited): if node == target: return True
visited.add(node) for neighbor in graph[node]: if neighbor not in visited: if dfs(neighbor, visited): return True
return False
return dfs(start, set())
def breadth_first_search(graph, start, target): """ Time: O(V + E) Space: O(V) for queue """ from collections import deque
queue = deque([start]) visited = {start}
while queue: node = queue.popleft() if node == target: return True
for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) queue.append(neighbor)
return FalseDynamic Programming Complexity
Memoization vs Tabulation
def fibonacci_recursive(n): """ Time: O(2ⁿ) - Exponential without memoization Space: O(n) - Recursion stack """ if n <= 1: return n return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
def fibonacci_memoized(n, memo={}): """ Time: O(n) - Each subproblem solved once Space: O(n) - Memoization table + recursion stack """ if n in memo: return memo[n]
if n <= 1: return n
memo[n] = fibonacci_memoized(n - 1, memo) + fibonacci_memoized(n - 2, memo) return memo[n]
def fibonacci_tabulation(n): """ Time: O(n) - Single loop Space: O(n) - DP table """ if n <= 1: return n
dp = [0] * (n + 1) dp[1] = 1
for i in range(2, n + 1): dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
def fibonacci_optimized(n): """ Time: O(n) Space: O(1) - Only store last two values """ if n <= 1: return n
prev2, prev1 = 0, 1
for i in range(2, n + 1): current = prev1 + prev2 prev2, prev1 = prev1, current
return prev1How to Analyze Complexity
Step-by-Step Process
- Identify the Basic Operations: What operations are performed most frequently?
- Count the Operations: How many times are these operations executed?
- Express as a Function of Input Size: How does the count relate to input size n?
- Simplify Using Big O Rules: Remove constants and lower-order terms
Big O Rules
- Drop Constants: O(3n) → O(n)
- Drop Lower-Order Terms: O(n² + n) → O(n²)
- Multiplication: O(n) × O(m) → O(nm)
- Addition: O(n) + O(m) → O(n + m)
Analysis Examples
def example_1(arr): """ Analysis: - Single loop: n iterations - Constant work per iteration: O(1) - Total: O(n) × O(1) = O(n) """ total = 0 # O(1) for num in arr: # O(n) total += num # O(1) return total # O(1) # Overall: O(n)
def example_2(arr): """ Analysis: - Outer loop: n iterations - Inner loop: n iterations per outer iteration - Total: O(n) × O(n) = O(n²) """ for i in range(len(arr)): # O(n) for j in range(len(arr)): # O(n) print(arr[i] + arr[j]) # O(1) # Overall: O(n²)
def example_3(arr): """ Analysis: - First loop: O(n) - Second loop: O(n) - Total: O(n) + O(n) = O(n) """ # First pass for num in arr: # O(n) print(num) # O(1)
# Second pass for num in arr: # O(n) print(num * 2) # O(1)
# Overall: O(n) + O(n) = O(n)
def example_4(arr): """ Analysis: - Outer loop: n iterations - Inner loop: i iterations (0, 1, 2, ..., n-1) - Total: 0 + 1 + 2 + ... + (n-1) = n(n-1)/2 = O(n²) """ for i in range(len(arr)): # O(n) for j in range(i): # O(i), average O(n/2) print(arr[i], arr[j]) # O(1) # Overall: O(n²)
def example_5(arr, target): """ Analysis: - While loop divides array in half each iteration - Maximum log₂(n) iterations - Constant work per iteration - Total: O(log n) """ left, right = 0, len(arr) - 1
while left <= right: # O(log n) mid = (left + right) // 2 # O(1) if arr[mid] == target: # O(1) return mid # O(1) elif arr[mid] < target: # O(1) left = mid + 1 # O(1) else: # O(1) right = mid - 1 # O(1)
return -1 # O(1) # Overall: O(log n)Optimization Techniques
Time Complexity Optimization
1. Use Appropriate Data Structures
# Slow: O(n) lookup in listdef find_in_list(items, target): return target in items # O(n)
# Fast: O(1) average lookup in setdef find_in_set(items_set, target): return target in items_set # O(1) average
# Example usageitems = [1, 2, 3, 4, 5] * 1000items_set = set(items)
# O(n) vs O(1) lookup time2. Memoization for Recursive Problems
# Without memoization: O(2ⁿ)def fibonacci_slow(n): if n <= 1: return n return fibonacci_slow(n - 1) + fibonacci_slow(n - 2)
# With memoization: O(n)def fibonacci_fast(n, memo={}): if n in memo: return memo[n] if n <= 1: return n memo[n] = fibonacci_fast(n - 1, memo) + fibonacci_fast(n - 2, memo) return memo[n]3. Two Pointers Technique
# Naive approach: O(n²)def find_pair_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
# Optimized approach: O(n) for sorted arraysdef find_pair_sum_fast(arr, target): left, right = 0, len(arr) - 1 while left < right: current = arr[left] + arr[right] if current == target: return (left, right) if current < target: left += 1 else: right -= 1 return NoneThe optimized version assumes the array is sorted and uses two pointers that meet in the middle, yielding O(n) time after sorting. If the data is unsorted, the dominant cost becomes the O(n log n) sort.
Interview Questions and Answers
Q1: What is the difference between Big O, Big Ω, and Big Θ notation?
A1: Big O describes an upper bound on growth, Big Ω provides a lower bound, and Big Θ indicates that an algorithm is bounded both above and below by the same rate. In practice, Big O captures worst-case, Big Ω best-case, and Big Θ tight bounds when they coincide.
Q2: How do you analyze the time complexity of nested loops?
A2: Multiply the work done by each loop level. If an outer loop runs n times and an inner loop runs n times per iteration, the work totals n × n = n². When bounds depend on the loop variable (e.g., inner loop runs i times), sum the series (Σᵢ i) to find the final order (n² in that case).
Q3: How does the Master Theorem help analyze divide-and-conquer recurrences?
A3: The Master Theorem gives closed-form complexity for recurrences of the form T(n) = aT(n/b) + f(n). Compare f(n) to n^{log_b a}: if f(n) grows slower, T(n) ≈ n^{log_b a}; if it matches, multiply by log n; if it grows faster and satisfies regularity conditions, T(n) ≈ f(n).
Q4: What is amortized analysis and when is it useful?
A4: Amortized analysis averages the cost of operations over a sequence, showing that expensive operations happen rarely. Dynamic array resizing is classic: although occasional resizes cost O(n), the average push remains O(1) because the total work over n pushes is O(n).
Q5: Why do we ignore constant factors and lower-order terms in Big O?
A5: Big O focuses on growth trends as n becomes large. Constants and lower-order terms contribute little compared to the dominant term, so they are omitted to emphasize scalability and algorithmic behavior rather than machine-specific timings.
Q6: When would you choose an O(n log n) algorithm over an O(n²) one despite higher constants?
A6: For large inputs, the n log n growth will overtake any constant advantage an n² algorithm might have. Sorting is a prime example: mergesort’s O(n log n) beats bubble sort’s O(n²) once n grows beyond small datasets.
Q7: How do you measure space complexity?
A7: Count the additional memory an algorithm allocates relative to the input size, including recursion stack frames, auxiliary data structures, and temporary buffers. Input data itself is typically considered given unless the algorithm copies it.
Q8: How does recursion affect space complexity?
A8: Each recursive call pushes a new frame on the call stack. A recursion depth of n implies O(n) stack space even if the algorithm uses constant additional memory per call. Tail recursion can often be optimized away, but languages vary in support.
Q9: What is the complexity of binary search tree operations?
A9: On a balanced BST, search, insert, and delete run in O(log n) time. In the worst case of a skewed tree (e.g., inserting sorted data into an unbalanced BST), the height becomes n and operations degrade to O(n).
Q10: How do average-case and worst-case complexities differ, using quicksort as an example?
A10: Quicksort’s average complexity is O(n log n) because pivots typically split the array evenly. In the worst case—when pivots are consistently the largest or smallest element—it becomes O(n²). Randomized pivot selection reduces the probability of worst-case behavior.
Q11: Why is the distinction between polynomial and exponential time important?
A11: Polynomial-time algorithms (e.g., O(n²), O(n³)) scale feasibly even for large inputs, while exponential-time algorithms (e.g., O(2ⁿ), O(n!)) become intractable quickly. Complexity classes like P vs NP hinge on these differences.
Q12: How can you reduce an O(n²) algorithm to O(n log n) or O(n)?
A12: Look for redundant work: use hashing to replace nested lookups, sort once and process linearly, or apply divide-and-conquer strategies that break problems into smaller subproblems solved more efficiently.
Q13: What factors influence the practical performance of an O(1) hash table lookup?
A13: O(1) assumes low load factor and uniform hashing. In practice, collisions, poor hash functions, or rehashing overhead can push lookups toward O(n). Choosing a good hash and resizing policy keeps performance near constant.
Q14: How do you analyze consecutive loops versus nested loops?
A14: Consecutive loops add their costs (O(n) + O(n) = O(n)), while nested loops multiply (O(n × n) = O(n²)). Distinguishing between the two patterns quickly shows whether complexity is linear or quadratic.
Q15: What is the time complexity of BFS and DFS on different graph representations?
A15: On adjacency lists, both BFS and DFS run in O(V + E). On adjacency matrices, they become O(V²) because scanning neighbors requires checking all V entries per vertex. Representation choice impacts performance.
Q16: How does dynamic programming change time complexity compared with naive recursion?
A16: By storing subproblem results, dynamic programming reduces exponential blow-ups to polynomial time. Fibonacci shifts from O(2ⁿ) naive recursion to O(n) with memoization or tabulation.
Q17: How do theoretical complexity and empirical runtime measurements complement each other?
A17: Big O predicts scaling trends but ignores constants, cache behavior, and hardware. Benchmarking validates assumptions, reveals constant-factor issues, and guides optimizations that theory alone cannot capture.
Q18: How do you handle algorithms with multiple input variables (e.g., m rows and n columns)?
A18: Express complexity using both variables, such as O(mn) for scanning a matrix. Avoid collapsing them unless you know their relationship; otherwise you risk misrepresenting growth.
Q19: Can you give an example of a time-space trade-off?
A19: Precomputing prefix sums uses O(n) extra space to answer range-sum queries in O(1) time instead of computing sums on demand in O(n). Caching sacrifices memory to speed up repeated operations.
Q20: What steps do you take to analyze an algorithm during an interview?
A20: Clarify inputs and constraints, identify core operations, count how often they execute, express results in terms of n, simplify to Big O, and check space usage. State best, average, and worst cases when they differ, and justify assumptions.