Time & Space Complexity

The visual guide to analyzing algorithm efficiency — Big O notation, recursion trees, and complexity for every interview pattern.

Complexity analysis answers: how does runtime (or memory) grow as input size grows? We express this with Big O notation.

Why not just time it? Wall-clock time depends on hardware and OS load. Big O describes the mathematical relationship between input size and operations — valid on any machine.
n

The input size — array length, node count, string length, etc.

T(n)

The exact number of operations as a function of n

O(f(n))

Upper bound — T(n) grows no faster than f(n) times a constant

Best case Ω

Lower bound on runtime — the algorithm is at least this fast

Average Θ

Tight bound — the algorithm runs in exactly this class on average

Worst case O

Upper bound — interviews always ask for this one

Big O growth curves chart
Notice how O(2ⁿ) shoots off the chart before n=7, while O(1) and O(log n) are nearly flat — this visual gap is why algorithm choice matters so much for large inputs.

Actual Operation Counts for Common Input Sizes

nO(log n)O(n)O(n log n)O(n²)O(2ⁿ)
1031033100~1K
1007100664~10K
1,000101,000~10K~1000K
10,00013~10K~133K~100M
1,000,00020~1000K~20M~1000B
< 10M ops — fast 10M–100M — approaching limit 100M–1B — probably TLE > 1B — TLE

Bar width represents relative "slowness" on a log scale for n = 10,000. The longer the bar, the more operations.

O(1)Constant — array index, hash map get/set, stack push/pop
Perfect
O(log n)Logarithmic — binary search, balanced BST, heap push/pop
Excellent
O(n)Linear — single loop, BFS/DFS, linear scan
Good
O(n log n)Linearithmic — merge sort, heap sort, most built-in sorts
Fair
O(n²)Quadratic — nested loops over same input, bubble sort
Slow
O(n³)Cubic — triple nested loops, Floyd-Warshall (dense graph)
Very Slow
O(2ⁿ)Exponential — all subsets enumeration (brute force)
Avoid
O(n!)Factorial — all permutations (brute force TSP)
Never for n>12
Interview sizing rule: ≈ 10⁸ ops/second. n ≤ 10⁵ → O(n log n) fine. n ≤ 10³ → O(n²) fine. n ≤ 25 → O(2ⁿ) fine.
1

Drop Constants

Multiplicative constants are dropped. Hardware absorbs constants; Big O cares only about growth shape.

Python example
# Two separate loops → 2n → O(n)  (NOT O(2n))
for i in range(n):
    do_a(i)
for i in range(n):
    do_b(i)

# 100 constant ops inside a loop → still O(n)
for i in range(n):
    x = a + b          # O(1)
    y = c * d          # O(1)
    # ... 98 more O(1) operations
2

Drop Lower-Order Terms

Keep only the dominant term. For large n, n² completely dwarfs n.

Python simplification examples
O(n² + n)     →  O(n²)
O(n log n + n) →  O(n log n)
O(2ⁿ + n³)    →  O(2ⁿ)
O(500)        →  O(1)

# In code:
for i in range(n):              # O(n²) — dominant
    for j in range(n):
        process(i, j)

for k in range(n):              # + O(n) — dropped
    scan(k)
# Total: O(n²)
3

Sequential → Add. Nested → Multiply.

add (sequential)
# O(n) + O(m) = O(n + m)
def two_arrays(a, b):
    for x in a:              # O(n)
        print(x)
    for y in b:              # O(m)
        print(y)
# → O(n + m)  (keep both!)
multiply (nested)
# O(n) × O(m) = O(n·m)
def pairs(a, b):
    for x in a:              # O(n)
        for y in b:          # × O(m)
            print(x, y)
# → O(n·m)
When two inputs are different (arrays a and b of sizes n and m), write O(n + m) or O(n·m) — never collapse to O(n²)unless you know n = m.
4

Recursion = Work per Call × Number of Calls

Draw the recursion tree. Count nodes (calls) × work per node.

Python fibonacci — O(2ⁿ)
# Each call spawns 2 more → binary tree of calls
# Tree has ≈ 2ⁿ nodes, O(1) work each → O(2ⁿ)
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)  # 2 calls

Merge sort splits the array in half each time (producing log n levels), then merges each level back together — doing O(n) total work per level.

Minimal merge sort recursion tree diagram

Levels in tree

log₂n = log₂8 = 3

Work per level

O(n) merge ops

Total

O(n) × log n = O(n log n)

This same analysis applies to any divide-and-conquer algorithm that splits in half and does O(n) work to recombine. Quicksort is O(n log n) on average for the same reason, but O(n²) worst-case when pivots are poorly chosen.
Two PointersO(n)O(1)

Each pointer moves at most n steps. Even with both moving, combined steps ≤ 2n. Drop the constant → O(n).

Sliding WindowO(n)O(k)

right advances n times, left advances at most n times total. Each element enters and leaves the window once.

Binary SearchO(log n)O(1)

Each comparison halves the remaining space. Starting at n, after k steps we have n/2^k elements. k = log₂n when that reaches 1.

Prefix SumO(n) build + O(1) queryO(n)

One pass to build. Any range sum [i,j] = prefix[j] − prefix[i−1] — a single subtraction.

Monotonic StackO(n)O(n)

Every element is pushed once and popped at most once. Total push + pop operations = 2n.

Heap / Top-KO(n log k)O(k)

Each of n elements is pushed into a k-sized heap. Heap push/pop = O(log k). Total: n × log k.

BFS / DFS (Graph)O(V + E)O(V)

Each vertex visited once (O(V)). Each edge inspected once from both endpoints (O(E)). Queue holds at most O(V).

Tree DFSO(n)O(h) stack

Every node visited once. Stack depth = tree height h. Balanced: h = O(log n). Skewed: h = O(n).

Merge Sort / SortO(n log n)O(n)

log n levels of recursion. O(n) merge work per level. Python's sorted() is Timsort — same bound.

1D DPO(n)O(n)→O(1)

Fill n states, each O(1). Rolling array optimization: drop space to O(1) when only previous 1–2 states needed.

2D DPO(m × n)O(mn)→O(n)

Fill every cell in m×n table. Rolling rows reduces space to O(n).

BacktrackingO(bᵈ)O(d)

b = branching factor, d = depth. Pruning reduces the constant but not the worst-case class.

Union-FindO(α(n)) ≈ O(1)O(n)

With path compression + union by rank, α(n) < 5 for any practical n (inverse Ackermann function).

Topological SortO(V + E)O(V + E)

Same analysis as BFS — each vertex and edge processed exactly once via Kahn's algorithm.

IntervalsO(n log n)O(n)

Sort by start time (O(n log n)) then one linear pass to merge/process (O(n)). Dominated by sort.

Space complexity counts all memory your algorithm allocates, excluding the read-only input (unless explicitly storing it).

Variables & Primitives

Each int, bool, pointer = O(1)

Call Stack

Each recursive frame counts. Depth h → O(h) space

Data Structures

Arrays, sets, maps, queues you create — count their size

Output Space

Often excluded from auxiliary analysis — ask your interviewer

Python recursive vs iterative — space trade-off
# RECURSIVE factorial
# Time: O(n) · Space: O(n) — n frames on call stack
def factorial_recursive(n):
    if n <= 1:
        return 1
    return n * factorial_recursive(n - 1)  # stack grows with each call

# ITERATIVE factorial
# Time: O(n) · Space: O(1) — single variable, no stack growth
def factorial_iterative(n):
    result = 1
    for value in range(2, n + 1):
        result *= value
    return result

# DFS on balanced tree of height h = log n
# Time: O(n) · Space: O(log n) — call stack = height
def dfs(node):
    if node is None:
        return 0
    return 1 + dfs(node.left) + dfs(node.right)
Interviewers often ask you to trade space for time. A hash map reduces O(n²) brute-force lookup to O(n) time at the cost of O(n) space. Learn to recognize and articulate these trade-offs.
  1. 1

    Identify input variables

    Name them: n = array length, m = string length, V = vertices, E = edges. Keep separate variables for separate inputs.

  2. 2

    Find every loop and recursive call

    Mark each loop with how many times it runs in terms of your variables. Nested → multiply. Sequential → add.

  3. 3

    Cost of inner work

    Is inner work O(1)? Does it call sort() → O(n log n)? A set lookup → O(1)? Multiply loop count × inner cost.

  4. 4

    Handle recursion with the tree

    Width at each tree level × work per node = cost per level. Sum across log n or n levels.

  5. 5

    Apply the four rules

    Drop constants. Drop lower-order terms. Combine with + or ×. Simplify to dominant term.

  6. 6

    Count space separately

    List every data structure and call stack depth. Sum them. Drop lower-order space terms too.

Python worked example — two sum O(n) time · O(n) space
def two_sum(nums, target):
    # Step 1: n = len(nums)
    seen = {}  # Space: O(n) — dictionary grows up to n entries

    for i, value in enumerate(nums):       # Step 2: loop runs n times
        complement = target - value         # Step 3: O(1) arithmetic
        if complement in seen:              #         O(1) hash lookup
            return [seen[complement], i]
        seen[value] = i                     #         O(1) hash insert
    return []                               # Inner work: O(1)

# Time:  n × O(1) = O(n)
# Space: O(n) — the dictionary

Never classify a loop by its syntax alone. Track how the control variable changes and count the values it can take. A variable that doubles is logarithmic; a variable that grows by a fixed amount is linear; a variable that resets creates a multiplication.

geometric progress — O(log n)
value = 1
while value < n:
    work(value)
    value *= 2

# Values: 1, 2, 4, 8, ...
# After k iterations: 2^k >= n → k = O(log n)
shrinking by a constant fraction — O(log n)
remaining = n
while remaining > 1:
    work(remaining)
    remaining //= 3

# n, n/3, n/9, ... → O(log n) iterations
triangular loop — O(n²)
for i in range(n):
    j = i
    while j < n:
        work(i, j)
        j += 1

# Work = n + (n - 1) + ... + 1 = n(n + 1)/2 = O(n²)
square-root loop — O(√n)
i = 1
while i * i <= n:
    work(i)
    i += 1

# The loop stops when i reaches √n → O(√n)
Two loops are not automatically O(n²). If the inner pointer never resets and only moves forward across the entire function, the total work can still be O(n).
nested-looking but linear — O(n)
left = 0
for right in range(n):
    while left < right and invalid(left, right):
        left += 1

# right moves n times; left also moves at most n times total.
# Total pointer moves ≤ 2n → O(n), not O(n²).

Binary divide + linear combine

O(n log n)
T(n) = 2T(n/2) + O(n)Merge sort

One smaller recursive call

O(log n)
T(n) = T(n/2) + O(1)Binary search

Two calls shrinking by one

O(2ⁿ)
T(n) = 2T(n − 1) + O(1)Naive Fibonacci

Linear work at every level

O(n²)
T(n) = T(n − 1) + O(n)Repeated prefix work

For a recurrence, write down the number of recursive calls, the size of each subproblem, and the non-recursive work done in the current call. Then use a recursion tree: add the work across one level, count the number of levels, and multiply when each level has the same cost.

amortized analysis — dynamic array growth
items = []
for value in values:
    items.append(value)

# Most append operations cost O(1).
# An occasional resize copies k existing items and costs O(k).
# Across n appends, copied totals are 1 + 2 + 4 + ... + n < 2n.
# Total cost = O(n), so average/amortized append = O(1).
Amortized O(1) does not mean every operation is O(1). It means a long sequence of operations has O(1) average cost per operation. State this distinction explicitly in interviews.
hidden work — slicing changes the answer
def bad_merge_sort(values):
    if len(values) <= 1:
        return values
    mid = len(values) // 2
    left = bad_merge_sort(values[:mid])   # slicing copies O(n) here
    right = bad_merge_sort(values[mid:])  # slicing copies O(n) here
    return merge(left, right)             # O(n)

# Recurrence: T(n) = 2T(n/2) + O(n) → O(n log n)
# The slices add O(n) per level, but do not change the class here.
# In a different recursive algorithm, repeated slices can add another factor.

String concatenation inside a loop

"result += char" in a loop can be O(n²) — each concat may copy the whole string. Use a list with append() and join() at the end → O(n).

Calling sort() inside a loop

values.sort() inside an n-iteration loop = O(n² log n) total. Pull sorting out of loops.

Assuming list membership is O(1)

value in a list scans linearly → O(n). Convert to a set first for average O(1) lookups.

Treating two different inputs as one

If you take lists A (size n) and B (size m), write O(n + m) or O(n·m), never O(n²) unless you know n = m.

Ignoring slicing cost

values[mid:] creates a new list in O(k), where k is the slice length. Inside recursion it multiplies cost. Use index pointers instead.

Forgetting call stack space

A recursion of depth n uses O(n) call-stack space even if each frame is O(1). Stack overflow is possible for n > ~10,000.

Misidentifying amortized complexity

Array push is O(1) amortized, not always O(1). Resizing is O(n) but spread over n pushes gives O(1) average. Don't count the resize cost per push.

OperationTime
List index [i]O(1)
list.append() / pop()O(1) amortized
list.insert(0) / pop(0)O(n)
List slice [i:j]O(j − i)
sorted(list)O(n log n)
value in listO(n)
value in set / dictO(1) avg
set.add() / dict[key] = valO(1) amortized
String += charO(n) per op
String slice [i:j]O(k)
Binary searchO(log n)
Heap push / popO(log n)
Build heap from arrayO(n)
Trie insert / searchO(L)
Union-Find find (with PC)O(α(n)) ≈ O(1)
BFS / DFS on graphO(V + E)
Recursive DFS on treeO(n) time · O(h) space

Built-in Toolkit & Data Structures

Standard Library
CategoryPython patternUse it forCost
DP / Cache@cache (or @lru_cache(None))Zero-boilerplate DP memoizationO(1) lookup
Recursionsys.setrecursionlimit(200_000)Prevent stack overflow on deep trees/DFSO(1)
Dictcollections.defaultdict(list)Graph adjacency list & groupingO(1) avg
Dictcollections.defaultdict(int)Character & number frequency countingO(1) avg
Dictcollections.Counter(values)Frequency maps, anagram checksO(n) build
Countercounts.most_common(k)Top-k elements via internal heapO(n log k)
Setseen.add(x) / x in seenDeduplication and constant-time lookupO(1) avg
Queuecollections.deque(); q.popleft()BFS & sliding-window (never list.pop(0)!)O(1)
Heapheapify(nums)Turn list into min-heap in-placeO(n)
Heapheappush(h, x) / heappop(h)Priority queue, Dijkstra, Top-KO(log k)
Heapheappushpop(h, x)Push then pop smallest in one stepO(log k)
Searchbisect_left(arr, x)First index where element >= xO(log n)
Searchbisect_right(arr, x)First index where element > xO(log n)
Iterationfor i, val in enumerate(arr)Index and value togetherO(n)
Iterationfor a, b in zip(xs, ys)Parallel walk; stops at shorter arrayO(min(n, m))
Matrixzip(*matrix)Transpose matrix (turn columns into rows)O(rows × cols)
Sortingsorted(arr, key=lambda x: (x[0], -x[1]))Multi-key sort (asc by A, desc by B)O(n log n)
Logicany(cond(x) for x in arr)Short-circuit check: True on first matchO(n) worst
Logicall(cond(x) for x in arr)Short-circuit check: False on first failO(n) worst
Strings"".join(char_list)Join strings (never string += char in loop)O(n)
Stringss.isalnum() / isalpha() / isdigit()Validate characters in palindromesO(len(s))
Stringss[::-1] / reversed(s)Reverse string or listO(n)
Mathdivmod(a, b)Returns (a // b, a % b) togetherO(1)
Mathmath.gcd(a, b) / math.lcm(a, b)Greatest common divisor & LCMO(log min(a,b))
Mathfloat("inf") / -float("inf")Initial min/max distance & DP valuesO(1)

Bit Manipulation Quick Reference

OperationBitwise formulaInterview application
Check odd / evenx & 1Returns 1 if odd, 0 if even
Clear lowest set bitx & (x - 1)Count set bits in O(k) steps (Brian Kernighan)
Isolate lowest set bitx & -xIsolates the rightmost 1-bit
Check power of twox > 0 and (x & (x - 1)) == 0Powers of 2 have exactly one 1-bit
XOR identityx ^ x == 0 and x ^ 0 == xFind the Single Number among pairs in O(n)
Check k-th bit(x >> k) & 1Inspect state in bitmasks / subset problems
Toggle k-th bitx ^ (1 << k)Flip bit from 0 to 1 or 1 to 0
Set k-th bitx | (1 << k)Turn on state in bitmask DP

5 Dangerous Python LeetCode Traps

1. The 2D Array Reference Bug

grid = [[0] * cols] * rows
grid = [[0] * cols for _ in range(rows)]

The first line copies row references. Modifying grid[0][0] mutates every single row!

2. Negative Integer Division

-3 // 2 # Returns -2 (rounds to -inf)
int(-3 / 2) # Returns -1 (truncates to 0)

Python // rounds toward negative infinity. LeetCode questions (like Evaluate Reverse Polish) expect truncation toward zero.

3. String Concatenation in Loops

res = ""; for c in s: res += c
res = "".join(chars)

Strings are immutable. Repeated += copies the string each time, turning an O(n) loop into O(n²).

4. Recursive Slicing Overhead

dfs(nums[:mid]) + dfs(nums[mid:])
dfs(left, mid) + dfs(mid + 1, right)

Slicing creates a new list in O(k) time and memory. Inside recursion, pass index boundaries instead.

Battle-Tested Interview Templates

memoization & recursion template
from functools import cache
import sys

sys.setrecursionlimit(200_000)

@cache  # Automatically memoizes state
def dp(i, target):
    if target == 0:
        return 0
    if i == len(nums) or target < 0:
        return float('inf')

    take = 1 + dp(i, target - nums[i])
    skip = dp(i + 1, target)
    return min(take, skip)
binary search via bisect
from bisect import bisect_left, bisect_right

arr = [1, 2, 4, 4, 4, 7, 9]

# First index >= target (left insertion)
idx_l = bisect_left(arr, 4)    # -> 2

# First index > target (right insertion)
idx_r = bisect_right(arr, 4)   # -> 5

# Frequency count in O(log n)
freq = idx_r - idx_l           # -> 3

# Check target existence in O(log n)
exists = idx_l < len(arr) and arr[idx_l] == 4
monotonic stack (next greater element)
# Next Greater Element in O(n) time & space
def next_greater(nums):
    stack = []  # Monotonic decreasing indices
    res = [-1] * len(nums)

    for i, num in enumerate(nums):
        # Resolve elements smaller than current
        while stack and nums[stack[-1]] < num:
            prev = stack.pop()
            res[prev] = num
        stack.append(i)

    return res

# Usage: next_greater([2, 1, 2, 4, 3])
union-find (path compression & rank)
class UnionFind:
    def __init__(self, n):
        self.p = list(range(n))
        self.count = n  # Component count

    def find(self, x):
        # Path compression: O(alpha(n))
        if self.p[x] != x:
            self.p[x] = self.find(self.p[x])
        return self.p[x]

    def union(self, x, y):
        rx, ry = self.find(x), self.find(y)
        if rx == ry: return False
        self.p[ry] = rx
        self.count -= 1; return True
matrix BFS / flood fill
from collections import deque

queue = deque([(start_r, start_c)])
seen = {(start_r, start_c)}
DIRS = ((0, 1), (1, 0), (0, -1), (-1, 0))

while queue:
    r, c = queue.popleft()
    for dr, dc in DIRS:
        nr, nc = r + dr, c + dc
        # Check matrix bounds & unvisited
        if 0 <= nr < R and 0 <= nc < C:
            if (nr, nc) not in seen:
                seen.add((nr, nc))
                queue.append((nr, nc))
topological sort (Kahn's algorithm)
from collections import deque

adj = [[] for _ in range(n)]
deg = [0] * n
for u, v in edges:
    adj[u].append(v); deg[v] += 1

q = deque(i for i in range(n) if deg[i] == 0)
order = []
while q:
    node = q.popleft()
    order.append(node)
    for nxt in adj[node]:
        deg[nxt] -= 1
        if deg[nxt] == 0: q.append(nxt)
In Python interviews, always explain your complexity assumptions: dictionary and set lookups are average O(1), `heapq` push/pop are O(log k), and `deque.popleft()` is O(1) compared to `list.pop(0)` which is O(n).