Historical Context Complexity analysis as a formal discipline emerged from two threads. Alan Turing's 1936 paper on computability established the theoretical foundation — the Turing machine as a model of computation. In the 1960s, Robert Tarjan and John Hopcroft developed formal complexity classes (P, NP, PSPACE). Donald Knuth's *The Art of Computer Programming* (1968) pioneered the systematic analysis of algorithm efficiency, introducing Big-O notation into mainstream CS. The Cook-Levin theorem (1971) established NP-completeness, connecting complexity theory to the most important open problem in mathematics: P vs NP. Today, complexity analysis is essential for every software engineer — it determines whether a system can handle 10,000 or 10,000,000 requests, and whether a database query takes 10 milliseconds or 10 minutes.## Why Complexity Analysis Matters
A system that handles 1,000 requests per second at USD 10,000 per month in compute costs is Fundamentally different from one that handles 10 requests per second at the same cost. The Difference is almost always algorithmic: the data structure, the traversal strategy, the caching Policy. Before you optimise constants, before you add hardware, before you profile — understand the Asymptotic behaviour of your algorithm.
Complexity analysis gives you a language for reasoning about how an algorithm scales. It abstracts Away machine-specific details (clock speed, cache size, instruction set) and lets you compare Algorithms on their mathematical properties. This is not academic; it is the difference between a Query that completes in 10 milliseconds and one that takes 10 minutes when the dataset grows from 10,000 to 10,000,000 rows.
O ( g ( n ) ) O(g(n)) O ( g ( n )) is the set of all functions f ( n ) f(n) f ( n ) for which there exist positive constants c c c and n 0 n_0 n 0 Such that:
0 \le f(n) \le c \cdot g(n) \quad \mathrm{for all n \ge n_0
Big-O provides an upper bound on the growth rate of a function. Saying f ( n ) = O ( n 2 ) f(n) = O(n^2) f ( n ) = O ( n 2 ) means That f ( n ) f(n) f ( n ) grows no faster than n 2 n^2 n 2 (up to a constant factor), for sufficiently large n n n .
## Example: nested loop is O(n^2)
def print_all_pairs ( arr ):
for i in range ( len (arr)): # O(n)
for j in range ( len (arr)): # O(n)
print (arr[i], arr[j]) # O(1)
## Total: O(n) * O(n) * O(1) = O(n^2)
Ω ( g ( n ) ) \Omega(g(n)) Ω ( g ( n )) is the set of all functions f ( n ) f(n) f ( n ) for which there exist positive constants c c c and n 0 n_0 n 0 such that:
0 \le c \cdot g(n) \le f(n) \quad \mathrm{for all n \ge n_0
Big-Omega provides a lower bound . If an algorithm is Ω ( n log n ) \Omega(n \log n) Ω ( n log n ) It means no matter how Clever your implementation, the algorithm will take at least c ⋅ n log n c \cdot n \log n c ⋅ n log n steps for large n n n .
Θ ( g ( n ) ) \Theta(g(n)) Θ ( g ( n )) is the intersection: f ( n ) ∈ Θ ( g ( n ) ) f(n) \in \Theta(g(n)) f ( n ) ∈ Θ ( g ( n )) if and only if f ( n ) ∈ O ( g ( n ) ) f(n) \in O(g(n)) f ( n ) ∈ O ( g ( n )) and f ( n ) ∈ Ω ( g ( n ) ) f(n) \in \Omega(g(n)) f ( n ) ∈ Ω ( g ( n )) . This is the tight bound — the function grows at exactly the same rate As g ( n ) g(n) g ( n ) Up to constant factors.
0 \le c_1 \cdot g(n) \le f(n) \le c_2 \cdot g(n) \quad \mathrm{for all n \ge n_0
Conventionally understood. When someone says "merge sort is $O(n \log n)$" they mean it is $\Theta(n \log n)$. Be aware of the distinction when reading academic papers.f ( n ) = o ( g ( n ) ) f(n) = o(g(n)) f ( n ) = o ( g ( n )) means f ( n ) f(n) f ( n ) grows strictly slower than g ( n ) g(n) g ( n ) :
lim n → ∞ f ( n ) g ( n ) = 0 \lim_{n \to \infty} \frac{f(n)}{g(n)} = 0 lim n → ∞ g ( n ) f ( n ) = 0
Similarly, f ( n ) = ω ( g ( n ) ) f(n) = \omega(g(n)) f ( n ) = ω ( g ( n )) means f ( n ) f(n) f ( n ) grows strictly faster. These are strict versions of Big-O and Big-Omega respectively — they exclude the equality case.
Notation Meaning Intuition O ( g ( n ) ) O(g(n)) O ( g ( n )) f ( n ) ≤ c ⋅ g ( n ) f(n) \le c \cdot g(n) f ( n ) ≤ c ⋅ g ( n ) At most this fast Ω ( g ( n ) ) \Omega(g(n)) Ω ( g ( n )) f ( n ) ≥ c ⋅ g ( n ) f(n) \ge c \cdot g(n) f ( n ) ≥ c ⋅ g ( n ) At least this fast Θ ( g ( n ) ) \Theta(g(n)) Θ ( g ( n )) c 1 ⋅ g ( n ) ≤ f ( n ) ≤ c 2 ⋅ g ( n ) c_1 \cdot g(n) \le f(n) \le c_2 \cdot g(n) c 1 ⋅ g ( n ) ≤ f ( n ) ≤ c 2 ⋅ g ( n ) Exactly this fast o ( g ( n ) ) o(g(n)) o ( g ( n )) f ( n ) / g ( n ) → 0 f(n) / g(n) \to 0 f ( n ) / g ( n ) → 0 Strictly slower ω ( g ( n ) ) \omega(g(n)) ω ( g ( n )) f ( n ) / g ( n ) → ∞ f(n) / g(n) \to \infty f ( n ) / g ( n ) → ∞ Strictly faster
graph TD
O1["O(1) — Constant"]
Ologn["O(log n) — Logarithmic"]
On["O(n) — Linear"]
Onlogn["O(n log n) — Linearithmic"]
On2["O(n²) — Quadratic"]
On3["O(n³) — Cubic"]
O2n["O(2^n) — Exponential"]
Onf["O(n!) — Factorial"]
O1 --> Ologn
Ologn --> On
On --> Onlogn
Onlogn --> On2
On2 --> On3
On3 --> O2n
O2n --> Onf
style O1 fill:#27ae60,color:#fff
style Ologn fill:#2ecc71,color:#fff
style On fill:#f1c40f,color:#333
style Onlogn fill:#f39c12,color:#fff
style On2 fill:#e74c3c,color:#fff
style On3 fill:#c0392b,color:#fff
style O2n fill:#8e44ad,color:#fff
style Onf fill:#2c3e50,color:#fff Class Name Example Practical at n = 10 6 n = 10^6 n = 1 0 6 O ( 1 ) O(1) O ( 1 ) Constant Hash table lookup Instantaneous O ( log n ) O(\log n) O ( log n ) Logarithmic Binary search ~20 operations O ( n ) O(n) O ( n ) Linear Single pass through array 1,000,000 operations O ( n log n ) O(n \log n) O ( n log n ) Linearithmic Merge sort ~20,000,000 operations O ( n 2 ) O(n^2) O ( n 2 ) Quadratic Bubble sort 10 12 10^{12} 1 0 12 operations (infeasible)O ( n 3 ) O(n^3) O ( n 3 ) Cubic Naive matrix multiplication 10 18 10^{18} 1 0 18 operations (infeasible)O ( 2 n ) O(2^n) O ( 2 n ) Exponential Subset enumeration 10 301 , 029 10^{301,029} 1 0 301 , 029 operations (impossible)O ( n ! ) O(n!) O ( n !) Factorial Permutation generation Beyond astronomical
The “practical at n = 10 6 n = 10^6 n = 1 0 6 ” column assumes roughly 10 9 10^9 1 0 9 operations per second (a single modern Core). In reality:
A cache miss costs ~100 cycles, so O ( n ) O(n) O ( n ) with poor cache locality can be slower than O ( n log n ) O(n \log n) O ( n log n ) with good locality. Parallelism changes the equation: O ( n log n ) O(n \log n) O ( n log n ) on 32 cores is effectively O ( n log n / 32 ) O(n \log n / 32) O ( n log n /32 ) . I/O dominates for large datasets: O ( n ) O(n) O ( n ) with 10 GB of random reads from disk is far slower than O ( n log n ) O(n \log n) O ( n log n ) with sequential reads. A well-optimised $O(n^2)$ algorithm can outperform a naive $O(n \log n)$ algorithm for small $n$ or With favourable cache behaviour. Always benchmark.If f 1 ( n ) = O ( g 1 ( n ) ) f_1(n) = O(g_1(n)) f 1 ( n ) = O ( g 1 ( n )) and f 2 ( n ) = O ( g 2 ( n ) ) f_2(n) = O(g_2(n)) f 2 ( n ) = O ( g 2 ( n )) Then f 1 ( n ) + f 2 ( n ) = O ( max ( g 1 ( n ) , G 2 ( n ) ) ) f_1(n) + f_2(n) = O(\max(g_1(n), G_2(n))) f 1 ( n ) + f 2 ( n ) = O ( max ( g 1 ( n ) , G 2 ( n ))) .
This is why we drop lower-order terms: O ( n 2 + n log n + 3 n ) = O ( n 2 ) O(n^2 + n \log n + 3n) = O(n^2) O ( n 2 + n log n + 3 n ) = O ( n 2 ) because n 2 n^2 n 2 dominates.
If f 1 ( n ) = O ( g 1 ( n ) ) f_1(n) = O(g_1(n)) f 1 ( n ) = O ( g 1 ( n )) and f 2 ( n ) = O ( g 2 ( n ) ) f_2(n) = O(g_2(n)) f 2 ( n ) = O ( g 2 ( n )) Then f 1 ( n ) ⋅ f 2 ( n ) = O ( g 1 ( n ) ⋅ G 2 ( n ) ) f_1(n) \cdot f_2(n) = O(g_1(n) \cdot G_2(n)) f 1 ( n ) ⋅ f 2 ( n ) = O ( g 1 ( n ) ⋅ G 2 ( n )) .
For any constant k k k , O ( n k ) O(n^k) O ( n k ) dominates O ( n j ) O(n^j) O ( n j ) when k > j k \gt j k > j .
For any constants a , b > 1 a, b \gt 1 a , b > 1 : log a n = log b n / log b a = O ( log n ) \log_a n = \log_b n / \log_b a = O(\log n) log a n = log b n / log b a = O ( log n ) . The base of the Logarithm does not matter in Big-O notation because changing base only introduces a constant factor.
For any constants k k k and c > 1 c \gt 1 c > 1 : n k = o ( c n ) n^k = o(c^n) n k = o ( c n ) . Polynomials are always asymptotically dominated By exponentials. This is the fundamental boundary between tractable and intractable problems.
The Master Theorem provides a closed-form solution for recurrences of the form:
T ( n ) = a ⋅ T ( n / b ) + f ( n ) T(n) = a \cdot T(n/b) + f(n) T ( n ) = a ⋅ T ( n / b ) + f ( n )
Where a ≥ 1 a \ge 1 a ≥ 1 and b > 1 b \gt 1 b > 1 . Let c c r i t = log b a c_{crit} = \log_b a c cr i t = log b a (the critical exponent).
If f ( n ) = O ( n c c r i t − ϵ ) f(n) = O(n^{c_{crit} - \epsilon}) f ( n ) = O ( n c cr i t − ϵ ) for some ϵ > 0 \epsilon \gt 0 ϵ > 0 Then T ( n ) = Θ ( n c c r i t ) T(n) = \Theta(n^{c_{crit}}) T ( n ) = Θ ( n c cr i t ) .
The recursive work at the leaves dominates the combine step.
Example: T ( n ) = 8 T ( n / 2 ) + O ( n ) T(n) = 8T(n/2) + O(n) T ( n ) = 8 T ( n /2 ) + O ( n )
a = 8 a = 8 a = 8 , b = 2 b = 2 b = 2 So c c r i t = log 2 8 = 3 c_{crit} = \log_2 8 = 3 c cr i t = log 2 8 = 3 f ( n ) = O ( n ) = O ( n 3 − 2 ) f(n) = O(n) = O(n^{3-2}) f ( n ) = O ( n ) = O ( n 3 − 2 ) So ϵ = 2 \epsilon = 2 ϵ = 2 T ( n ) = Θ ( n 3 ) T(n) = \Theta(n^3) T ( n ) = Θ ( n 3 ) If f ( n ) = Θ ( n c c r i t log k n ) f(n) = \Theta(n^{c_{crit}} \log^k n) f ( n ) = Θ ( n c cr i t log k n ) for some k ≥ 0 k \ge 0 k ≥ 0 Then T ( n ) = Θ ( n c c r i t log k + 1 n ) T(n) = \Theta(n^{c_{crit}} \log^{k+1} n) T ( n ) = Θ ( n c cr i t log k + 1 n ) .
The work is the same at each level of the recursion tree.
Example: T ( n ) = 2 T ( n / 2 ) + O ( n ) T(n) = 2T(n/2) + O(n) T ( n ) = 2 T ( n /2 ) + O ( n )
a = 2 a = 2 a = 2 , b = 2 b = 2 b = 2 So c c r i t = log 2 2 = 1 c_{crit} = \log_2 2 = 1 c cr i t = log 2 2 = 1 f ( n ) = O ( n ) = Θ ( n 1 log 0 n ) f(n) = O(n) = \Theta(n^1 \log^0 n) f ( n ) = O ( n ) = Θ ( n 1 log 0 n ) So k = 0 k = 0 k = 0 T ( n ) = Θ ( n log n ) T(n) = \Theta(n \log n) T ( n ) = Θ ( n log n ) — this is merge sortIf f ( n ) = Ω ( n c c r i t + ϵ ) f(n) = \Omega(n^{c_{crit} + \epsilon}) f ( n ) = Ω ( n c cr i t + ϵ ) for some ϵ > 0 \epsilon \gt 0 ϵ > 0 And a ⋅ f ( n / b ) ≤ C ⋅ f ( n ) a \cdot f(n/b) \le C \cdot f(n) a ⋅ f ( n / b ) ≤ C ⋅ f ( n ) for some C < 1 C \lt 1 C < 1 and all sufficiently large N N N (regularity Condition), then T ( n ) = Θ ( f ( n ) ) T(n) = \Theta(f(n)) T ( n ) = Θ ( f ( n )) .
The combine step dominates the recursive work.
Example: T ( n ) = 2 T ( n / 2 ) + O ( n 2 ) T(n) = 2T(n/2) + O(n^2) T ( n ) = 2 T ( n /2 ) + O ( n 2 )
a = 2 a = 2 a = 2 , b = 2 b = 2 b = 2 So c c r i t = 1 c_{crit} = 1 c cr i t = 1 f ( n ) = O ( n 2 ) = Ω ( n 1 + 1 ) f(n) = O(n^2) = \Omega(n^{1+1}) f ( n ) = O ( n 2 ) = Ω ( n 1 + 1 ) So ϵ = 1 \epsilon = 1 ϵ = 1 Regularity: 2 ⋅ ( n / 2 ) 2 = n 2 / 2 ≤ 0.5 ⋅ n 2 2 \cdot (n/2)^2 = n^2/2 \le 0.5 \cdot n^2 2 ⋅ ( n /2 ) 2 = n 2 /2 ≤ 0.5 ⋅ n 2 — satisfied T ( n ) = Θ ( n 2 ) T(n) = \Theta(n^2) T ( n ) = Θ ( n 2 ) def master_theorem ( a , b , f_case ):
Apply the Master Theorem for T(n) = a*T(n/b) + f(n).
if f_case == 1 : # f(n) = O(n^{c_crit - eps})
return f "Theta(n^ { c_crit } )"
elif f_case == 2 : # f(n) = Theta(n^{c_crit})
return f "Theta(n^ { c_crit } * log n)"
elif f_case == 3 : # f(n) = Omega(n^{c_crit + eps})
# Merge sort: a=2, b=2, f(n)=O(n), Case 2
print (master_theorem( 2 , 2 , 2 )) # Theta(n^1.0 * log n)
The Master Theorem does not apply when:
f ( n ) f(n) f ( n ) is not a simple polynomial or polylogarithmic functiona a a or b b b are not constantsThe subproblems are not all of size n / b n/b n / b (e.g., T ( n ) = T ( n / 3 ) + T ( 2 n / 3 ) + O ( n ) T(n) = T(n/3) + T(2n/3) + O(n) T ( n ) = T ( n /3 ) + T ( 2 n /3 ) + O ( n ) ) The recurrence is not of the form a T ( n / b ) + f ( n ) aT(n/b) + f(n) a T ( n / b ) + f ( n ) For these cases, use the recursion tree method or the Akra-Bazzi theorem (a generalisation that Handles subproblems of different sizes).
An algorithm”s complexity can vary depending on the input. Quicksort runs in O ( n log n ) O(n \log n) O ( n log n ) on Average but O ( n 2 ) O(n^2) O ( n 2 ) in the worst case. Linear search is O ( 1 ) O(1) O ( 1 ) in the best case (element is first) And O ( n ) O(n) O ( n ) in the worst case.
Case Definition When it matters Best case Minimum over all inputs of size n n n Rarely useful in practice Average case Expected value over a distribution of inputs Useful when input distribution is known Worst case Maximum over all inputs of size n n n The standard for guarantees
In systems engineering, worst-case guarantees matter because:
Adversarial inputs exist . Attackers can craft inputs that trigger worst-case behaviour (hash collision denial-of-service, regex backtracking)Tail latency is critical . P99 latency is dominated by worst-case behaviour, not averageReal-time constraints . A system that responds in 1ms but occasionally takes 10s is often worse than one that always responds in 5ms Requests with keys that all hash to the same bucket, turning $O(1)$ lookups into $O(n)$ lookups and Causing CPU exhaustion. This is why many languages (Python, Rust, Go) now use hash randomisation.Amortised analysis gives a tighter bound for a sequence of operations when individual operations may Be expensive but the expensive operations are rare enough that the total cost is bounded.
Compute the total cost of n n n operations and divide by n n n .
Dynamic array (e.g., Python listC++ std::vector):
Append is O ( 1 ) O(1) O ( 1 ) when there is capacity, O ( n ) O(n) O ( n ) when resizing is needed Resizing doubles the capacity: after growing from k k k to 2 k 2k 2 k The next k k k appends are O ( 1 ) O(1) O ( 1 ) Total cost for n n n appends: 1 + 1 + ⋯ + 1 + n + 1 + 1 + ⋯ 1 + 1 + \cdots + 1 + n + 1 + 1 + \cdots 1 + 1 + ⋯ + 1 + n + 1 + 1 + ⋯ where the n n n cost occurs at sizes 1 , 2 , 4 , 8 , … 1, 2, 4, 8, \ldots 1 , 2 , 4 , 8 , … Total: n + 1 + 2 + 4 + ⋯ + n = n + 2 n − 1 = 3 n − 1 n + 1 + 2 + 4 + \cdots + n = n + 2n - 1 = 3n - 1 n + 1 + 2 + 4 + ⋯ + n = n + 2 n − 1 = 3 n − 1 Amortised cost per operation: O ( 1 ) O(1) O ( 1 ) Assign an amortised cost to each operation. The amortised cost must be at least the actual cost. The surplus accumulates as credit that pays for future expensive operations.
For dynamic array append:
Assign amortised cost of 3 per append (actual cost is 1 when no resize, k + 1 k+1 k + 1 when resizing from k k k ) When no resize: spend 1, save 2 as credit (1 for the slot, 1 for future resizing) When resizing from k k k to 2 k 2k 2 k : the k k k items already have 1 credit each from previous inserts, providing k k k credit to pay for the k k k copies Credit never goes negative, so the amortised bound is valid Define a potential function Φ \Phi Φ on the data structure state. The amortised cost of operation i i i is:
c ^ i = c i + Φ ( D i ) − Φ ( D i − 1 ) \hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1}) c ^ i = c i + Φ ( D i ) − Φ ( D i − 1 )
Where c i c_i c i is the actual cost and Φ ( D i ) \Phi(D_i) Φ ( D i ) is the potential after the operation.
For a dynamic array with size n n n and capacity m m m :
Φ ( D ) = 2 n − m \Phi(D) = 2n - m Φ ( D ) = 2 n − m
After an O ( 1 ) O(1) O ( 1 ) insert (no resize): Φ \Phi Φ increases by 2, amortised cost = 1 + 2 = 3 1 + 2 = 3 1 + 2 = 3 After a resize from m m m to 2 m 2m 2 m : Φ \Phi Φ goes from 2 m − m = m 2m - m = m 2 m − m = m to 2 m − 2 m = 0 2m - 2m = 0 2 m − 2 m = 0 A drop of m m m Amortised cost = m + 0 − m = 0 m + 0 - m = 0 m + 0 − m = 0 (the actual cost of m m m is fully paid by the potential drop) Total amortised cost: O ( 1 ) O(1) O ( 1 ) per operation.
self .data = [ 0 ] * 1 # initial capacity = 1
if self .size == self .capacity:
# Resize: O(capacity) work, but amortised O(1)
new_data = [ 0 ] * ( self .capacity * 2 )
for i in range ( self .size):
new_data[i] = self .data[i]
self .data[ self .size] = value
# Amortised O(1) per append over n operations
# Total: n inserts + sum of resize costs = n + 1 + 2 + 4 + ... + n = 3n
Space complexity measures the additional memory an algorithm uses beyond the input. Like time Complexity, it is expressed in asymptotic notation.
Algorithm Time Space Notes In-place quicksort O ( n log n ) O(n \log n) O ( n log n ) avgO ( log n ) O(\log n) O ( log n ) Stack depth for recursion Merge sort O ( n log n ) O(n \log n) O ( n log n ) O ( n ) O(n) O ( n ) Auxiliary array Heap sort O ( n log n ) O(n \log n) O ( n log n ) O ( 1 ) O(1) O ( 1 ) True in-place DFS O ( V + E ) O(V + E) O ( V + E ) O ( V ) O(V) O ( V ) Recursion stack / explicit stack BFS O ( V + E ) O(V + E) O ( V + E ) O ( V ) O(V) O ( V ) Queue for frontier Dynamic programming (2D) Varies O ( n ⋅ m ) O(n \cdot m) O ( n ⋅ m ) Full table DP with rolling array Same time O ( min ( n , m ) ) O(\min(n, m)) O ( min ( n , m )) Space-optimised
Many algorithms can trade space for time or vice versa:
Memoisation trades O ( n ) O(n) O ( n ) space for exponential-to-polynomial time reductionBloom filters trade a small false positive rate for massive space savings (membership testing)Suffix arrays trade construction time for less space than suffix treesCounting sort trades O ( k ) O(k) O ( k ) space (where k k k is the range of values) for O ( n ) O(n) O ( n ) timeA lower bound is a proof that no algorithm in a given model of computation can do better than a Certain complexity.
Any comparison-based sorting algorithm requires Ω ( n log n ) \Omega(n \log n) Ω ( n log n ) comparisons in the worst case.
Proof sketch (decision tree argument):
A comparison-based sort can be modelled as a binary decision tree Each internal node represents a comparison, each leaf represents a permutation There are n ! n! n ! possible permutations of n n n elements A binary tree of height h h h has at most 2 h 2^h 2 h leaves Therefore: 2 h ≥ n ! 2^h \ge n! 2 h ≥ n ! So h ≥ log 2 ( n ! ) = Ω ( n log n ) h \ge \log_2(n!) = \Omega(n \log n) h ≥ log 2 ( n !) = Ω ( n log n ) (by Stirling’s approximation) This is why non-comparison sorts (counting sort, radix sort) can beat O ( n log n ) O(n \log n) O ( n log n ) — they do not Compare elements pairwise, so the decision tree argument does not apply.
Determining whether all elements in an array are distinct requires Ω ( n log n ) \Omega(n \log n) Ω ( n log n ) time in the Comparison model. This follows from the sorting lower bound (sort, then check adjacent elements).
Unordered array: Ω ( n ) \Omega(n) Ω ( n ) comparisons (must examine every element in the worst case) Sorted array: O ( log n ) O(\log n) O ( log n ) with binary search, and this is optimal for comparison-based search A decision problem is one whose answer is yes or no. Examples: “Does this graph have a Hamiltonian Cycle?” “Is there a subset of these numbers that sums to k k k ?”
Optimisation problems can often be reduced to decision problems: “What is the shortest tour?” Becomes “Is there a tour of length at most k k k ?” (binary search on k k k ).
Class Definition Example Problems P Solvable in polynomial time Sorting, shortest path, MST NP Verifiable in polynomial time SAT, travelling salesman, graph colouring NP-Complete In NP, and every NP problem reduces to it SAT, 3-SAT, vertex cover NP-Hard At least as hard as NP-complete (may not be in NP) Halting problem, TSP optimisation
graph TD
P["P<br/>Polynomial time"]
NP["NP<br/>Verifiable in polynomial time"]
NPC["NP-Complete<br/>Hardest problems in NP"]
NPH["NP-Hard<br/>At least as hard as NP"]
P --> NP
NPC --> NP
NPH -.-> NPC
style P fill:#27ae60,color:#fff
style NP fill:#3498db,color:#fff
style NPC fill:#e74c3c,color:#fff
style NPH fill:#8e44ad,color:#fff A problem A A A reduces to problem B B B (written A ≤ p B A \le_p B A ≤ p B ) if an algorithm for B B B can be used To solve A A A in polynomial time. If A A A is NP-complete and A ≤ p B A \le_p B A ≤ p B Then B B B is also NP-hard. If B B B is also in NP, then B B B is NP-complete.
Cook-Levin Theorem: SAT (Boolean satisfiability) is NP-complete. Every other NP-complete problem Is proven NP-complete by reducing from a known NP-complete problem.
Problem Input Question Practical Significance SAT Boolean formula Is there a satisfying assignment? Basis for all NP-completeness proofs 3-SAT 3-CNF formula Is there a satisfying assignment? Circuit design, scheduling Vertex Cover Graph G G G Integer k k k Is there a vertex cover of size ≤ k \le k ≤ k ? Network monitoring Travelling Salesman Graph with weights, integer k k k Is there a tour of length ≤ k \le k ≤ k ? Logistics, routing Subset Sum Set of integers, target t t t Is there a subset summing to t t t ? Knapsack variants Graph Colouring Graph G G G Integer k k k Can G G G be coloured with k k k colours? Register allocation, scheduling Clique Graph G G G Integer k k k Does G G G contain a clique of size k k k ? Social network analysis
When you encounter an NP-hard problem:
Restrict the input . Many NP-hard problems become polynomial on restricted inputs (e.g., TSP on a tree, graph colouring on a bipartite graph)Approximation algorithms . Find a solution within a guaranteed factor of optimal (e.g., 2-approx for vertex cover, 1.5-approx for metric TSP with Christofides’ algorithm)Heuristics . Greedy algorithms, local search, simulated annealing, genetic algorithms. No guarantees, but often work well in practiceFixed-parameter tractability . If the problem is NP-hard but polynomial for fixed parameter k k k Use FPT algorithms (e.g., vertex cover is O ( 2 k ⋅ n ) O(2^k \cdot n) O ( 2 k ⋅ n ) )SAT solvers . For many combinatorial problems, encoding as SAT and using a modern solver (CDCL-based) is surprisingly effectiveAsymptotic analysis ignores the memory hierarchy. In practice, cache effects dominate:
Sequential access (arrays): prefetcher-friendly, ~1 ns per access from L1 cacheRandom access (linked lists): cache-unfriendly, ~100 ns per miss to main memoryB-trees vs binary trees : B-trees are designed for disk/cache-line-sized blocks, reducing the number of cache misses per operation by a factor of log 2 B \log_2 B log 2 B where B B B is the block sizeA linked list traversal that is O ( n ) O(n) O ( n ) in theory can be 10-100x slower than an array traversal that Is also O ( n ) O(n) O ( n ) Because the array has spatial locality.
Big-O hides constant factors. O ( n ) O(n) O ( n ) with a constant of 1000 is slower than O ( n log n ) O(n \log n) O ( n log n ) with a Constant of 1 for n < 2 1000 n \lt 2^{1000} n < 2 1000 . In practice, the constants matter enormously:
Radix sort has O ( n ⋅ k ) O(n \cdot k) O ( n ⋅ k ) time but small constants and excellent cache behaviour, making it faster than comparison sort for integers in practice Insertion sort is O ( n 2 ) O(n^2) O ( n 2 ) but has tiny constants and is adaptive, making it the fastest sort for n < 50 n \lt 50 n < 50 or nearly-sorted data Modern CPUs deeply pipeline instructions and speculate on branch outcomes. A branch that is Unpredictable can cost 15-20 cycles per misprediction. Algorithms with unpredictable branching Patterns (e.g., quicksort on adversarial data, binary search on random data) suffer significantly.
Conditional moves (cmov instructions) and branchless implementations can eliminate misprediction Penalties for small inner loops:
# Branchless max (conceptual — actual implementation uses cmov)
def branchless_max ( a , b ):
# mask = (a - b) >> 31 (sign bit: 1 if a < b, 0 otherwise)
# result = a ^ ((a ^ b) & mask)
return a if a >= b else b
Saying “this algorithm is O ( 1 ) O(1) O ( 1 ) ” when you mean Θ ( 1 ) \Theta(1) Θ ( 1 ) is imprecise. Technically, every Algorithm is O ( 2 n ) O(2^n) O ( 2 n ) because O O O is only an upper bound. If you claim O ( 1 ) O(1) O ( 1 ) You should be Prepared to justify it as a tight bound.
Worst-case analysis is essential for guarantees, but average-case analysis matters for real Performance. Quicksort is O ( n 2 ) O(n^2) O ( n 2 ) worst case but O ( n log n ) O(n \log n) O ( n log n ) average case with a small constant — This is why it is the default sort in most standard libraries (with introsort fallback).
An O ( n ) O(n) O ( n ) time algorithm that uses O ( n 2 ) O(n^2) O ( n 2 ) space is often worse than an O ( n log n ) O(n \log n) O ( n log n ) algorithm That uses O ( 1 ) O(1) O ( 1 ) space. Memory is not infinite, and allocation is not free.
The Master Theorem requires the recurrence to be of the exact form T ( n ) = a T ( n / b ) + f ( n ) T(n) = aT(n/b) + f(n) T ( n ) = a T ( n / b ) + f ( n ) . If your Subproblems are of different sizes (e.g., quicksort’s T ( n ) = T ( k ) + T ( n − k − 1 ) + O ( n ) T(n) = T(k) + T(n-k-1) + O(n) T ( n ) = T ( k ) + T ( n − k − 1 ) + O ( n ) ), you need a Different analysis technique.
The O ( n log n ) O(n \log n) O ( n log n ) sorting lower bound only applies to comparison-based sorts. Counting sort, radix Sort, and bucket sort all beat this bound by using additional information about the input (integer Keys, bounded range, uniform distribution). Similarly, the element uniqueness lower bound is Ω ( n log n ) \Omega(n \log n) Ω ( n log n ) only in the comparison model.
When analysing complexity, focus on the operation that scales with input size. A hash table has O ( 1 ) O(1) O ( 1 ) average-case lookup, but if your keys are strings and the hash function scans each character, The actual cost is O ( k ) O(k) O ( k ) where k k k is the key length. If k k k grows with n n n (e.g., storing all Substrings), the “constant-time” lookup is not actually constant.
Amortised O ( 1 ) O(1) O ( 1 ) means the average over many operations is constant. Individual operations can still Be O ( n ) O(n) O ( n ) . In a latency-sensitive system (real-time trading, game loop, audio processing), a single O ( n ) O(n) O ( n ) operation can cause a deadline miss even if the amortised cost is fine. Use data structures With worst-case guarantees (e.g., std::deque instead of std::vector with occasional reallocation) For real-time contexts.
Big-O notation hides constants, but constants matter in practice. An O ( n ) O(n) O ( n ) algorithm with a Constant of 10,000 is slower than an O ( n log n ) O(n \log n) O ( n log n ) algorithm with a constant of 1 for any n n n that Fits in memory. When comparing two algorithms with the same Big-O complexity, benchmark with Realistic data sizes. The constant factors include: number of memory accesses (cache misses Dominate), number of branches (mispredictions cost 15-20 cycles each), and allocation count (heap Allocations are orders of magnitude slower than stack allocations).
When the Master Theorem does not apply (e.g., unequal subproblem sizes), use the recursion tree Method. Draw the recursion tree, compute the work at each level, and sum across all levels.
Example: T ( n ) = T ( n / 3 ) + T ( 2 n / 3 ) + O ( n ) T(n) = T(n/3) + T(2n/3) + O(n) T ( n ) = T ( n /3 ) + T ( 2 n /3 ) + O ( n )
The recursion tree has:
Level 0: work O ( n ) O(n) O ( n ) 1 node of size n n n Level 1: work O ( n / 3 ) + O ( 2 n / 3 ) = O ( n ) O(n/3) + O(2n/3) = O(n) O ( n /3 ) + O ( 2 n /3 ) = O ( n ) 2 nodes Level 2: work O ( n / 9 ) + O ( 2 n / 9 ) + O ( 2 n / 9 ) + O ( 4 n / 9 ) = O ( n ) O(n/9) + O(2n/9) + O(2n/9) + O(4n/9) = O(n) O ( n /9 ) + O ( 2 n /9 ) + O ( 2 n /9 ) + O ( 4 n /9 ) = O ( n ) 4 nodes … Each level does O ( n ) O(n) O ( n ) total work The tree height is log 3 / 2 n \log_{3/2} n log 3/2 n (the longest root-to-leaf path goes by the 2/3 branch) Total: O ( n log n ) O(n \log n) O ( n log n ) The Akra-Bazzi theorem generalises the Master Theorem for recurrences of the form:
T ( x ) = ∑ i = 1 k a i T ( b i x + h i ( x ) ) + f ( x ) T(x) = \sum_{i=1}^{k} a_i T(b_i x + h_i(x)) + f(x) T ( x ) = ∑ i = 1 k a i T ( b i x + h i ( x )) + f ( x )
Where a i > 0 a_i \gt 0 a i > 0 , 0 < b i < 1 0 \lt b_i \lt 1 0 < b i < 1 And h i ( x ) = O ( x / log 2 x ) h_i(x) = O(x / \log^2 x) h i ( x ) = O ( x / log 2 x ) . Find p p p such that ∑ i = 1 k a i b i p = 1 \sum_{i=1}^{k} a_i b_i^p = 1 ∑ i = 1 k a i b i p = 1 . Then:
T ( x ) = Θ ( x p ( 1 + ∫ 1 x f ( u ) u p + 1 d u ) ) T(x) = \Theta\left(x^p \left(1 + \int_1^x \frac{f(u)}{u^{p+1}} du\right)\right) T ( x ) = Θ ( x p ( 1 + ∫ 1 x u p + 1 f ( u ) d u ) )
This handles cases like T ( n ) = T ( n / 3 ) + T ( 2 n / 3 ) + O ( n ) T(n) = T(n/3) + T(2n/3) + O(n) T ( n ) = T ( n /3 ) + T ( 2 n /3 ) + O ( n ) where the subproblem sizes are not equal.
For algorithms whose running time depends on the input distribution (e.g., quicksort), probabilistic Analysis gives expected running time over a random input. Quicksort with random pivot selection has Expected O ( n log n ) O(n \log n) O ( n log n ) comparisons, but the expected number of comparisons can be computed exactly:
E[\mathrm{comparisons] = 2(n+1)H_n - 4n \approx 1.386 n \log_2 n
Where H n = ∑ i = 1 n 1 / i H_n = \sum_{i=1}^{n} 1/i H n = ∑ i = 1 n 1/ i is the n n n -th harmonic number. The constant 1.386 1.386 1.386 is about 39% More comparisons than the information-theoretic minimum of n log 2 n n \log_2 n n log 2 n Which is remarkably close To optimal for a comparison sort.
Worst-case analysis can be too pessimistic for algorithms that perform well on typical inputs but Badly on adversarial ones. Smoothed analysis (Spielman and Teng, 2004) measures expected performance Under small random perturbations of the input. It explains why the simplex method for linear Programming is efficient in practice despite having exponential worst-case complexity: the Adversarial inputs that trigger exponential behaviour are unstable under small perturbations.
For online algorithms (where future input is unknown), competitive analysis compares the algorithm’s Performance to the optimal offline algorithm. An algorithm is c c c -competitive if its cost is at most c c c times the optimal cost for every input sequence.
Online Problem Algorithm Competitive Ratio Paging (caching) LRU k k k (where k k k = cache size)Paging (caching) FIFO k k k K-server Work function algorithm 2 k − 1 2k - 1 2 k − 1 Load balancing Greedy O ( log n ) O(\log n) O ( log n ) Ski rental Buy after n n n rentals 2
When data does not fit in memory, the cost model changes. The external memory model (Aggarwal and Vitter, 1988) counts:
I/O operations: transferring a block of size B B B between memory and diskMemory size: M M M words available in internal memoryDisk size: N N N words on diskAlgorithm Internal Memory External Memory (I/Os) Scanning O ( N ) O(N) O ( N ) timeO ( N / B ) O(N/B) O ( N / B ) I/OsSorting O ( N log N ) O(N \log N) O ( N log N ) timeO ( ( N / B ) log M / B ( N / B ) ) O((N/B) \log_{M/B}(N/B)) O (( N / B ) log M / B ( N / B )) I/OsBST search O ( log N ) O(\log N) O ( log N ) timeO ( log B N ) O(\log_B N) O ( log B N ) I/OsB-tree search O ( log N ) O(\log N) O ( log N ) timeO ( log B N ) O(\log_B N) O ( log B N ) I/Os
The gap between internal and external memory complexity is why B-trees exist: a binary tree search Does O ( log 2 N ) O(\log_2 N) O ( log 2 N ) I/Os (one per level), while a B-tree search does O ( log B N ) O(\log_B N) O ( log B N ) I/Os. For N = 10 9 N = 10^9 N = 1 0 9 and B = 100 B = 100 B = 100 , binary tree needs ~30 I/Os while B-tree needs ~5 I/Os — a 6x improvement.
Splay trees are self-adjusting BSTs with no explicit balance information. Every access is followed By a “splay” operation that moves the accessed node to the root using a sequence of rotations. The Amortised cost of each operation is O ( log n ) O(\log n) O ( log n ) Proven using the potential method.
The potential function for splay trees is:
\Phi(T) = \sum_{v \in T} \log_2(\mathrm{size(v))
Where size(v) is the number of nodes in the subtree rooted at v. The potential is always Non-negative and is O ( n log n ) O(n \log n) O ( n log n ) for an n n n -node tree.
Key properties:
No balance information stored — simpler implementation Access pattern adapts to workload — frequently accessed nodes move near the root Static optimality theorem: splay trees perform within a constant factor of the optimal static tree for any access sequence Working set theorem: if an item is accessed t t t times and there are l l l distinct items accessed since its last access, the amortised cost is O ( log l + log t ) O(\log l + \log t) O ( log l + log t ) Caches, and database buffer pools, a small set of hot items dominates access. Splay trees Automatically adapt to this pattern without any tuning parameters.When you encounter a recurrence that does not fit the Master Theorem, follow this systematic Approach:
Map out the recursive structure. At each level, record the number of subproblems and the size and Cost of each subproblem.
Sum the work across all subproblems at each level. Check if the work is increasing, decreasing, or Constant across levels.
Use geometric series formulas or other summation techniques to compute the total work.
Use induction to verify your answer. This catches errors in the tree analysis.
Example: T ( n ) = 2 T ( n / 2 ) + n log n T(n) = 2T(n/2) + n \log n T ( n ) = 2 T ( n /2 ) + n log n
The Master Theorem does not directly apply because f ( n ) = n log n f(n) = n \log n f ( n ) = n log n is not of the form n c log k n n^c \log^k n n c log k n for the critical exponent (Case 2 requires the same exponent as c c r i t c_{crit} c cr i t But log n \log n log n is not a power of n n n ).
Recursion tree:
Level 0: 1 node, cost n log n n \log n n log n Level 1: 2 nodes, cost 2 ⋅ ( n / 2 ) log ( n / 2 ) = n ( log n − 1 ) 2 \cdot (n/2) \log(n/2) = n (\log n - 1) 2 ⋅ ( n /2 ) log ( n /2 ) = n ( log n − 1 ) Level 2: 4 nodes, cost 4 ⋅ ( n / 4 ) log ( n / 4 ) = n ( log n − 2 ) 4 \cdot (n/4) \log(n/4) = n (\log n - 2) 4 ⋅ ( n /4 ) log ( n /4 ) = n ( log n − 2 ) Level k k k : 2 k 2^k 2 k nodes, cost n ( log n − k ) n (\log n - k) n ( log n − k ) Last level: log n \log n log n levels, n n n nodes of size 1, cost n n n Total: ∑ k = 0 log n − 1 n ( log n − k ) + n = n ∑ j = 1 log n j + n = n ⋅ log n ( log n + 1 ) 2 + n = O ( n log 2 n ) \sum_{k=0}^{\log n - 1} n(\log n - k) + n = n \sum_{j=1}^{\log n} j + n = n \cdot \frac{\log n (\log n + 1)}{2} + n = O(n \log^2 n) ∑ k = 0 l o g n − 1 n ( log n − k ) + n = n ∑ j = 1 l o g n j + n = n ⋅ 2 l o g n ( l o g n + 1 ) + n = O ( n log 2 n )
When an algorithm makes multiple recursive calls of different sizes, the analysis requires summing The costs of all calls.
Example: T ( n ) = T ( n / 3 ) + T ( 2 n / 3 ) + c n T(n) = T(n/3) + T(2n/3) + cn T ( n ) = T ( n /3 ) + T ( 2 n /3 ) + c n
The recursion tree has log 3 / 2 n \log_{3/2} n log 3/2 n levels (the longest path goes by the 2/3 branch). Each level Does c n cn c n work. Total: O ( n log n ) O(n \log n) O ( n log n ) .
Some algorithms reduce the problem size by a constant rather than a factor.
Example: Binary search — T ( n ) = T ( n / 2 ) + O ( 1 ) T(n) = T(n/2) + O(1) T ( n ) = T ( n /2 ) + O ( 1 )
This is a degenerate case of the Master Theorem with a = 1 a = 1 a = 1 , b = 2 b = 2 b = 2 : c c r i t = log 2 1 = 0 c_{crit} = \log_2 1 = 0 c cr i t = log 2 1 = 0 f ( n ) = O ( 1 ) = O ( n 0 ) f(n) = O(1) = O(n^0) f ( n ) = O ( 1 ) = O ( n 0 ) So Case 2 gives T ( n ) = O ( log n ) T(n) = O(\log n) T ( n ) = O ( log n ) .
Example: Euclidean GCD — T ( a , b ) = T ( b , a m o d b ) + O ( 1 ) T(a, b) = T(b, a \bmod b) + O(1) T ( a , b ) = T ( b , a mod b ) + O ( 1 )
The Euclidean GCD terminates in O ( log min ( a , b ) ) O(\log \min(a, b)) O ( log min ( a , b )) steps. This follows from Lamé’s theorem: the Number of steps is at most 5 times the number of digits in the smaller number.
def linear_selection ( arr , k ):
Select the k-th smallest element (0-indexed) using median-of-medians.
# Divide into groups of 5, find median of each
for i in range ( 0 , len (arr), 5 ):
medians.append( sorted (group)[ len (group) // 2 ])
# Recursively find median of medians
pivot = linear_selection(medians, len (medians) // 2 )
low = [x for x in arr if x < pivot]
high = [x for x in arr if x > pivot]
equal = [x for x in arr if x == pivot]
return linear_selection(low, k)
elif k < len (low) + len (equal):
return linear_selection(high, k - len (low) - len (equal))
The median-of-medians guarantees that at least 30% of elements are in each partition, giving the Recurrence T ( n ) ≤ T ( n / 5 ) + T ( 7 n / 10 ) + O ( n ) = O ( n ) T(n) \le T(n/5) + T(7n/10) + O(n) = O(n) T ( n ) ≤ T ( n /5 ) + T ( 7 n /10 ) + O ( n ) = O ( n ) by the Master Theorem (Case 3). This is the Algorithm that proves selection (finding the k k k -th smallest) can be done in linear worst-case time.
Asymptotic analysis tells you how an algorithm scales, but profiling tells you where time is Actually spent. In production systems, use both:
Benchmark with representative data: Synthetic benchmarks miss cache effects, branch prediction patterns, and I/O behaviour. Use production traces or realistic synthetic data.Profile before optimising: Use cProfile``perfOr VTune to identify the actual bottleneck. The bottleneck is often not where you expect it.Measure, don’t guess: A single cache miss (100 ns) is worth ~300 integer operations. An L1 cache hit (1 ns) is 100x faster than a main memory access (100 ns). These differences dwarf the constant factors that Big-O hides.Consider the full pipeline: An algorithm with better asymptotic complexity but worse cache behaviour may be slower in practice. Radix sort (O ( n k ) O(nk) O ( nk ) ) is often faster than quicksort (O ( n log n ) O(n \log n) O ( n log n ) ) for integers because it accesses memory sequentially. """Benchmark different sorting approaches on realistic data."""
data = [random.randint( 0 , 10 ** 6 ) for _ in range (n)]
timsort_time = timeit.timeit( lambda : sorted (data), number = 1 )
print ( f "TimSort: { timsort_time :.3f } s" )
# Compare with insertion sort for small data
small_data = [random.randint( 0 , 1000 ) for _ in range ( 100 )]
isort_time = timeit.timeit( lambda : insertion_sort(small_data[ : ]), number = 1000 )
msort_time = timeit.timeit( lambda : sorted (small_data[ : ]), number = 1000 )
print ( f "Insertion sort (100 elements, 1000 runs): { isort_time :.3f } s" )
print ( f "TimSort (100 elements, 1000 runs): { msort_time :.3f } s" )
On the actual production workload due to access patterns, data distribution, and interaction with Other system components. Always benchmark with realistic data and in a realistic environment.This topic covers the core concepts of complexity analysis, including underlying theory, practical implementation, and key applications.
Key concepts include:
Big O notation and complexity analysis searching algorithms (binary, linear) sorting algorithms (bubble, merge, quick) graph algorithms (Dijkstra, BFS, DFS) dynamic programming Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Research Connections Complexity theory connects to the most important open problem in mathematics: P vs NP (Clay Millennium Prize, $1M). If P = NP, many "hard" problems in cryptography, scheduling, and protein folding become efficiently solvable. If P ≠ NP, certain cryptographic schemes (RSA, AES) are provably secure. Current research directions include: fine-grained complexity (parameterised complexity, ETH), quantum complexity (BQP vs BPP), and circuit complexity (lower bounds for AC0, TC0). The field also intersects with machine learning: can neural networks efficiently approximate NP-hard problems?