Độ phức tạp thuật toánAlgorithmic Complexity
Mục lục
- Tổng quan
- Kiến thức nền tảng
- Sao không cứ bấm giờ mà đo
- Time complexity và space complexity
- Đánh đổi space–time
- Tính complexity từ code như thế nào
- Complexity của code đệ quy
- Khái niệm chính
- Asymptotic notation
- Các runtime thường gặp
- Tốc độ tăng trưởng thật sự quan trọng đến mức nào
- Best case, average case, worst case
- Amortized analysis
- Chỗ mô hình thôi không còn dự đoán được thực tế
- Best Practices
- Tài liệu tham khảo
Table of contents
- Overview
- Fundamentals
- Why not just time it
- Time complexity versus space complexity
- The space–time trade-off
- How to calculate complexity from code
- Complexity of recursive code
- Key Concepts
- Asymptotic notation
- The common runtimes
- How much the growth rate actually matters
- Best case, average case, worst case
- Amortized analysis
- Where the model stops predicting reality
- Best Practices
- References
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Algorithmic complexity là môn học về việc một thuật toán tốn bao nhiêu khi input của nó lớn dần — tốn bao nhiêu thời gian và bao nhiêu memory, biểu diễn dưới dạng hàm của kích thước input n. Đây là kỹ năng mang đi được nhiều nhất trong toàn bộ roadmap này. Cấu trúc đến rồi đi, ngôn ngữ thay đổi, nhưng khả năng nhìn vào một vòng lặp và nói “cái đó là O(n²) và nó sẽ không sống nổi với dữ liệu production” thì tồn tại mãi.
Lý do ta đo tốc độ tăng trưởng thay vì đo giây là vì giây không phải là thuộc tính của thuật toán. Chạy cùng một đoạn code trên laptop và trên server, bằng Python và bằng C, với cache lạnh và cache nóng, bạn sẽ nhận được những con số chênh nhau cả trăm lần — và chẳng con số nào nói cho bạn điều gì về bản thân thuật toán. Thứ thật sự là thuộc tính của thuật toán là chi phí co giãn ra sao: nhân đôi input thì khối lượng công việc nhân đôi, nhân bốn, hay giữ nguyên? Câu hỏi đó có một câu trả lời độc lập với máy móc, và câu trả lời đó quyết định code của bạn có chạy nổi ở quy mô lớn hay không.
Mức độ nghiêm trọng rất dễ bị đánh giá thấp cho tới khi bạn đặt con số vào. Một thuật toán O(n²) với n = 1000 làm một triệu phép toán — tức thì. Cũng thuật toán đó với n = 1.000.000 làm 1012 phép toán, với tốc độ một tỷ phép toán mỗi giây thì tương đương khoảng mười bảy phút. Trong khi đó một thuật toán O(n log n) trên cùng input một triệu phần tử chỉ làm khoảng 2 × 107 phép toán: hai mươi mili giây. Không có compiler flag nào, không có CPU nhanh hơn nào, và không có bao nhiêu lần viết lại vòng lặp trong cùng nào lấp được khoảng cách năm mươi nghìn lần. Khoảng cách đó được định đoạt ngay khi bạn chọn thuật toán, và phân tích complexity là cách bạn nhìn thấy nó trước khi ship.
Ghi chú này bao gồm: đo cái gì (time vs space), suy ra complexity từ code như thế nào, những runtime bạn sẽ gặp trong thực tế, ký hiệu hình thức (O, Ω, θ) dùng để phát biểu kết quả, sự phân biệt best/average/worst case, amortized analysis, và — quan trọng không kém — chỗ mà toàn bộ khung lý thuyết này thôi không còn dự đoán tốt thời gian thực tế nữa.
Kiến thức nền tảng
Sao không cứ bấm giờ mà đo
Đo đạc thực sự hữu ích và bạn nên làm, nhưng nó không thay thế được phân tích, vì ba lý do:
- Một phép đo là một điểm; complexity là cả đường cong. Bấm giờ function của bạn trên fixture 500 dòng chẳng nói được gì về bảng production 5.000.000 dòng. Toàn bộ kiểu thất bại của code bậc hai là ở chỗ nó trông rất ổn lúc test.
- Phép đo bị nhiễu. Overhead của interpreter, thời gian JIT khởi động, garbage collection, trạng thái cache, việc CPU đổi tần số, và cái máy ảo hàng xóm ồn ào trên cloud — tất cả đều làm xê dịch con số. Phân tích thì không bị ảnh hưởng bởi bất cứ thứ nào trong số đó.
- Phân tích làm được trước khi code tồn tại. Bạn có thể so sánh hai thiết kế trên bảng trắng. Bạn không thể benchmark thứ bạn chưa viết.
Quy trình đúng là làm cả hai: phân tích để chọn thuật toán, đo đạc để bắt những thứ mà phân tích đã trừu tượng hóa đi mất (hằng số, cache, hành vi của allocator). Phân tích mà không đo thì ship ra galactic algorithm; đo mà không phân tích thì ship ra code chết ở quy mô gấp 10.
Time complexity và space complexity
Time complexity đếm số phép toán sơ cấp mà thuật toán thực hiện, như một hàm của kích thước input. Space complexity đếm lượng memory nó cần, cũng như một hàm của kích thước input.
Space complexity có một điểm tinh tế đáng chốt lại. Có hai thứ bạn có thể đang muốn nói tới:
- Tổng bộ nhớ — tính cả bản thân input. Bất kỳ thuật toán nào đọc một array
nphần tử đều dùng ít nhấtO(n)tổng bộ nhớ. - Bộ nhớ phụ (auxiliary space) — phần memory thuật toán cấp phát thêm ngoài input. Đây gần như luôn là con số đáng quan tâm, và khi ai đó nói “merge sort tốn
O(n)bộ nhớ còn quicksort tốnO(log n)”, họ đang nói về auxiliary space.
Auxiliary space bao gồm cả những thứ bạn hay quên: recursion call stack (một frame cho mỗi lời gọi đang hoạt động, nên đệ quy sâu d tốn O(d) dù mỗi frame rất nhỏ), các array tạm, bảng memoization, và cả output nếu bạn dựng ra một cái mới thay vì sửa tại chỗ.
| Thuật toán | Time | Auxiliary space | Vì sao |
|---|---|---|---|
| Tính tổng array bằng vòng lặp | O(n) | O(1) | một biến tích lũy |
| Binary search (lặp) | O(log n) | O(1) | hai chỉ số |
| Binary search (đệ quy) | O(log n) | O(log n) | một stack frame mỗi tầng |
| Merge sort | O(n log n) | O(n) | buffer để merge |
| Quicksort (in-place) | O(n log n) trung bình | O(log n) trung bình | chỉ có recursion stack |
| Heapsort | O(n log n) | O(1) | sắp xếp ngay trong array |
Counting sort (miền giá trị k) | O(n + k) | O(k) | mảng đếm |
| Fibonacci đệ quy ngây thơ | O(2^n) | O(n) | độ sâu stack là n |
| Fibonacci có memo | O(n) | O(n) | bảng memo |
| Fibonacci lặp | O(n) | O(1) | hai biến |
Đánh đổi space–time
Ba dòng cuối bảng trên chính là sự đánh đổi thu nhỏ. Bạn gần như luôn có thể dùng memory mua lấy thời gian, và đôi khi dùng thời gian mua lấy memory:
# O(2^n) time, O(n) space — tính đi tính lại cùng những subproblem, số lần theo hàm mũ.
def fib_naive(n):
if n < 2:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
# O(n) time, O(n) space — bỏ memory ra để nhớ những đáp án đã tính rồi.
def fib_memo(n, memo=None):
if memo is None:
memo = {}
if n < 2:
return n
if n not in memo:
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
# O(n) time, O(1) space — để ý là bạn chỉ cần đúng hai giá trị gần nhất.
def fib_iter(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
fib_naive(35) mất vài giây; fib_memo(35) và fib_iter(35) là tức thì. Đây chính là toàn bộ ý tưởng đằng sau quy hoạch động. Vài trường hợp đánh đổi khác gặp hằng ngày: hash table cố tình lãng phí memory (luôn để trống một phần) để giữ lookup ở O(1); database index lưu hẳn một cấu trúc sorted riêng để việc đọc không phải quét toàn bảng; cache lưu bản sao dư thừa để một request bỏ qua được việc tính lại. Precomputation, memoization, indexing và caching đều là cùng một nước cờ.
Đánh đổi cũng đi theo chiều ngược lại. Nén dữ liệu đổi thời gian CPU lấy dung lượng nhỏ hơn. Sắp xếp in-place đổi một thuật toán phức tạp hơn chút lấy bộ nhớ phụ O(1). Bạn muốn đi chiều nào phụ thuộc vào tài nguyên nào đang khan hiếm — và trên phần cứng nhúng hay trong một ngân sách memory chật chội, space thắng.
Tính complexity từ code như thế nào
Quy trình máy móc là: đếm số phép toán như một hàm của n, rồi rút gọn bằng cách chỉ giữ lại số hạng tăng nhanh nhất và bỏ đi các hệ số hằng. Bốn quy tắc làm gần hết công việc.
Quy tắc 1 — công việc hằng số là O(1). Phép số học, so sánh, gán, truy cập array theo chỉ số, và tra hash — mỗi cái tính là một bước, bất kể giá trị lớn nhỏ ra sao.
Quy tắc 2 — các block tuần tự thì cộng. Nếu block A là O(f(n)) và block B là O(g(n)), chạy A rồi B là O(f(n) + g(n)), rút gọn thành O(max(f, g)).
Quy tắc 3 — vòng lặp lồng nhau thì nhân. Một vòng lặp chạy f(n) lần mà thân của nó tốn g(n) sẽ là O(f(n) · g(n)).
Quy tắc 4 — bỏ hằng số và các số hạng bậc thấp. 3n² + 5n + 100 là O(n²). Hằng số bị bỏ vì chúng phụ thuộc vào máy; số hạng bậc thấp bị bỏ vì với n lớn chúng vô hình bên cạnh số hạng dẫn đầu. Tại n = 10⁶, n² là 1012 còn 5n là 5 × 106 — số hạng thấp đóng góp 0,0005% tổng số.
Áp dụng vào code thật:
# --- O(1): công việc không phụ thuộc n ---
def get_middle(arr):
return arr[len(arr) // 2] # một phép tính chỉ số, một lần truy cập
# --- O(n): một lượt duyệt ---
def total(arr):
s = 0 # 1 bước
for x in arr: # n lần lặp
s += x # 1 bước mỗi lần -> n bước
return s # 1 bước
# 2n + 2 -> O(n)
# --- O(n): các vòng lặp tuần tự thì CỘNG, không phải nhân ---
def sum_and_max(arr):
s = 0
for x in arr: # n
s += x
m = arr[0]
for x in arr: # + n
m = max(m, x)
return s, m # 2n -> O(n)
# --- O(n^2): vòng lặp lồng nhau thì NHÂN ---
def has_duplicate_pair(arr, target):
for i in range(len(arr)): # n
for j in range(len(arr)): # x n
if i != j and arr[i] + arr[j] == target:
return True
return False # n*n -> O(n^2)
# --- Vẫn là O(n^2): vòng lặp tam giác là n(n-1)/2, và hằng số thì bỏ ---
def all_pairs(arr):
pairs = []
for i in range(len(arr)): # n
for j in range(i + 1, len(arr)): # n-i-1, trung bình n/2
pairs.append((arr[i], arr[j]))
return pairs # n(n-1)/2 -> O(n^2)
# --- O(log n): biến lặp được NHÂN chia, không phải cộng thêm ---
def count_halvings(n):
steps = 0
while n > 1: # n -> n/2 -> n/4 -> ... -> 1
n //= 2
steps += 1
return steps # log2(n) lần lặp -> O(log n)
# --- O(n log n): một vòng lặp tuyến tính chứa một vòng lặp logarit ---
def total_halvings(arr):
steps = 0
for x in arr: # n
steps += count_halvings(x) # x O(log x)
return steps # O(n log n)
# --- O(n): KHÔNG phải O(n^2). Vòng trong bị chặn tổng, không lặp lại từ đầu. ---
def longest_run(arr):
best, i = 0, 0
while i < len(arr): # trông có vẻ lồng nhau...
j = i
while j < len(arr) and arr[j] == arr[i]:
j += 1 # ...nhưng j không bao giờ lùi lại,
best = max(best, j - i) # nên vòng trong chỉ tiến tổng cộng
i = j # n lần trên TOÀN BỘ lần chạy.
return best # O(n)
Ví dụ cuối là cái người ta hay sai nhất. Lồng nhau về cú pháp không đồng nghĩa với nhân lên về chi phí. Câu hỏi đúng không bao giờ là “lồng sâu mấy tầng” mà là “dòng này thực thi tổng cộng bao nhiêu lần, trên toàn bộ lần chạy”.
Một dạng thứ hai đáng nhận diện là vòng lặp điều hòa, trông như bậc hai nhưng không phải:
# Sàng Eratosthenes: vòng lặp trong chạy n/2 + n/3 + n/5 + ... lần.
# Tổng đó là n * (tổng nghịch đảo các số nguyên tố < n) = O(n log log n).
def primes_up_to(n):
is_prime = [True] * (n + 1)
is_prime[0] = is_prime[1] = False
p = 2
while p * p <= n: # vòng ngoài: O(sqrt(n)) giá trị p
if is_prime[p]:
for multiple in range(p * p, n + 1, p): # n/p bước
is_prime[multiple] = False
p += 1
return [i for i, prime in enumerate(is_prime) if prime]
# tổng: O(n log log n), gần như tuyến tính
Đây là bảng tóm tắt để so khớp mẫu:
| Dạng code | Complexity |
|---|---|
| Số câu lệnh cố định, không vòng lặp | O(1) |
Một vòng lặp qua n phần tử | O(n) |
Hai vòng lặp tuần tự qua n | O(n) — chúng cộng |
Vòng lặp trong vòng lặp, cả hai qua n | O(n²) |
Vòng lặp lồng dạng tam giác (j bắt đầu từ i) | O(n²) — hệ số ½ bị bỏ |
Ba vòng lặp lồng nhau qua n | O(n³) |
while n > 1: n //= 2 | O(log n) |
Vòng lặp qua n có binary search hoặc thao tác heap bên trong | O(n log n) |
| Hai con trỏ chỉ tiến về phía trước | O(n) — tổng quãng đường bị chặn |
Cửa sổ trượt qua n | O(n) — cùng lý do (./23-two-pointers-and-sliding-window.md) |
Đệ quy chia đôi, gộp lại tốn O(n) | O(n log n) |
Đệ quy chia đôi, công việc O(1) | O(n) |
Đệ quy gọi 2 nhánh trên n-1 | O(2^n) |
Liệt kê mọi tập con của n phần tử | O(2^n) |
Liệt kê mọi hoán vị của n phần tử | O(n!) |
Complexity của code đệ quy
Vòng lặp thì bạn đếm; đệ quy thì bạn biến thành một hệ thức truy hồi — một phương trình biểu diễn chi phí cho kích thước n theo chi phí của các input nhỏ hơn — rồi giải nó.
def merge_sort(arr):
if len(arr) <= 1: # base case: O(1)
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # T(n/2)
right = merge_sort(arr[mid:]) # T(n/2)
return merge(left, right) # O(n)
def merge(a, b):
out, i, j = [], 0, 0
while i < len(a) and j < len(b): # mỗi phần tử được chuyển đúng một lần
if a[i] <= b[j]:
out.append(a[i]); i += 1
else:
out.append(b[j]); j += 1
out.extend(a[i:]); out.extend(b[j:])
return out # O(len(a) + len(b))
Hệ thức truy hồi là T(n) = 2T(n/2) + O(n), với T(1) = O(1). Vẽ recursion tree ra: mỗi tầng làm tổng cộng O(n) công việc (các lần merge ở tầng đó phủ mỗi phần tử đúng một lần), và có log₂ n tầng vì kích thước giảm một nửa mỗi lần. Vậy T(n) = O(n log n).
Master Theorem biến việc này thành tra bảng cho các hệ thức dạng T(n) = a·T(n/b) + f(n):
| Hệ thức truy hồi | Nghiệm | Ví dụ |
|---|---|---|
T(n) = T(n/2) + O(1) | O(log n) | binary search |
T(n) = T(n/2) + O(n) | O(n) | quickselect (trung bình) |
T(n) = 2T(n/2) + O(1) | O(n) | duyệt cây, heapify |
T(n) = 2T(n/2) + O(n) | O(n log n) | merge sort, quicksort (trung bình) |
T(n) = 2T(n/2) + O(n²) | O(n²) | công việc ở tầng trên cùng áp đảo |
T(n) = T(n-1) + O(1) | O(n) | đệ quy tuyến tính, tính tổng list |
T(n) = T(n-1) + O(n) | O(n²) | quicksort worst case, selection sort |
T(n) = 2T(n-1) + O(1) | O(2^n) | Fibonacci ngây thơ, Tháp Hà Nội |
T(n) = n·T(n-1) + O(1) | O(n!) | sinh mọi hoán vị |
Phần suy diễn được triển khai đầy đủ trong ./19-recursion-and-divide-and-conquer.md.
Khái niệm chính
Asymptotic notation
Asymptotic notation mô tả hành vi tiệm cận của một hàm khi đối số của nó lớn dần, bỏ đi các hệ số hằng và các số hạng bậc thấp. Ba ký hiệu gánh gần như toàn bộ trọng lượng, và chúng nói những điều khác nhau — một sự phân biệt mà người ta thường xuyên làm mờ đi.
Big-O — cận trên. f(n) = O(g(n)) nếu tồn tại các hằng số dương c và n₀ sao cho
0 ≤ f(n) ≤ c · g(n) với mọi n ≥ n₀
Nói bằng lời: vượt qua một kích thước input nào đó, f tăng không nhanh hơn g, sai khác một hệ số hằng. Big-O là cái trần. Đây là ký hiệu bạn gặp nhiều nhất vì trong thực tế bạn quan tâm nhất tới “tệ nhất thì có thể tệ tới đâu”.
Big-Ω — cận dưới. f(n) = Ω(g(n)) nếu tồn tại các hằng số dương c và n₀ sao cho
0 ≤ c · g(n) ≤ f(n) với mọi n ≥ n₀
Vượt qua một kích thước input nào đó, f tăng ít nhất là nhanh bằng g. Big-Ω là cái sàn. Công dụng quan trọng nhất của nó là phát biểu giới hạn cho bài toán chứ không phải cho thuật toán: “mọi thuật toán sắp xếp dựa trên so sánh đều cần Ω(n log n) phép so sánh” là một khẳng định rằng không thuật toán nào, hiện tại hay tương lai, phá được cận đó — và đó là điều khiến merge sort và heapsort tối ưu một cách có chứng minh trong mô hình đó (./08-sorting-algorithms.md).
Big-θ — cận chặt. f(n) = θ(g(n)) nếu tồn tại các hằng số dương c₁, c₂ và n₀ sao cho
0 ≤ c₁ · g(n) ≤ f(n) ≤ c₂ · g(n) với mọi n ≥ n₀
Tương đương, θ(g) = O(g) ∩ Ω(g): f bị kẹp giữa g từ cả hai phía, nên g chính xác là tốc độ tăng trưởng. Đây thường là mô tả trung thực về chi phí của một thuật toán, và cũng là điều mà phần lớn mọi người muốn nói khi họ nói Big-O.
Hai ký hiệu nữa, ít dùng hơn nhưng đáng nhận biết:
- little-o —
f(n) = o(g(n))nghĩa làftăng nghiêm ngặt chậm hơn: bất đẳng thức đúng với mọicdương, không chỉ với mộtcnào đó.n = o(n²)là đúng;n² = o(n²)là sai. - little-ω — phiên bản nghiêm ngặt của
Ω.n² = ω(n).
| Ký hiệu | Ý nghĩa | Tương tự như | Dùng điển hình |
|---|---|---|---|
O(g) | tăng không nhanh hơn g | ≤ | ”thuật toán này tốn nhiều nhất là…” |
Ω(g) | tăng ít nhất nhanh bằng g | ≥ | ”bài toán này đòi hỏi ít nhất là…” |
θ(g) | tăng đúng bằng g | = | ”đây là tốc độ tăng trưởng thật” |
o(g) | tăng nghiêm ngặt chậm hơn g | < | tách các lớp complexity |
ω(g) | tăng nghiêm ngặt nhanh hơn g | > | tách các lớp complexity |
Một chứng minh mẫu, vì các định nghĩa dễ tin hơn sau khi bạn đã dùng chúng một lần. Khẳng định: 3n² + 5n + 2 = θ(n²).
- Cận trên. Với
n ≥ 1ta cón ≤ n²và1 ≤ n², nên3n² + 5n + 2 ≤ 3n² + 5n² + 2n² = 10n². Lấyc₂ = 10,n₀ = 1. Vậy làO(n²). - Cận dưới. Với
n ≥ 1,3n² + 5n + 2 ≥ 3n². Lấyc₁ = 3,n₀ = 1. Vậy làΩ(n²). - Cả hai đều đúng, nên
θ(n²). ∎
Hai cái bẫy về ký hiệu. Thứ nhất, dấu = trong f(n) = O(g(n)) là một sự lạm dụng ký hiệu — O(g(n)) là một tập hợp các hàm, và phát biểu trung thực phải là f(n) ∈ O(g(n)). Hệ quả là quan hệ này không đối xứng: bạn có thể viết f(n) = O(n²) nhưng không bao giờ được viết O(n²) = f(n).
Thứ hai, Big-O là cận trên, không phải lời hứa về sự chặt chẽ. Binary search là O(log n), và nó cũng — hoàn toàn đúng — là O(n), O(n²) và O(2^n), vì tất cả đều là những cái trần hợp lệ. Nói “binary search là O(n²)” vừa đúng vừa vô dụng. Khi ai đó phát biểu một complexity, họ gần như luôn có ý nói tới cận chặt nhất mà họ biết, tức là θ; quy ước này vô hại miễn là bạn nhớ rằng riêng O không cấm thuật toán chạy nhanh hơn.
Các runtime thường gặp
| Lớp | Tên | Nhân đôi n thì công việc thế nào? | Ví dụ kinh điển |
|---|---|---|---|
O(1) | Hằng số | không đổi | truy cập array theo chỉ số, tra hash |
O(log n) | Logarit | thêm một bước | binary search |
O(n) | Tuyến tính | nhân đôi | quét tuyến tính |
O(n log n) | Tuyến tính-logarit | nhiều hơn gấp đôi một chút | merge sort |
O(n²), O(n³), O(n^k) | Đa thức | ×4, ×8, ×2^k | bubble sort, nhân ma trận |
O(2^n) | Hàm mũ | bình phương lên | liệt kê tập con |
O(n!) | Giai thừa | không tả nổi | TSP vét cạn |
O(1) — hằng số. Thời gian chạy độc lập với kích thước input. Truy cập arr[i] tốn như nhau dù array chứa mười phần tử hay mười triệu, vì địa chỉ được tính bằng số học chứ không phải đi tìm. Lưu ý “hằng số” không có nghĩa là “nhanh” — một thao tác hằng số có thể gồm cả nghìn lệnh; nó chỉ có nghĩa là số lượng đó không tăng theo n.
def get(arr, i):
return arr[i] # address = base + i*size: O(1)
def is_even(n):
return n % 2 == 0 # O(1)
O(log n) — logarit. Thuật toán loại bỏ một tỉ lệ hằng số của phần input còn lại ở mỗi bước, nên số bước bằng số lần bạn chia đôi được n trước khi chạm 1. Đây là tốc độ tăng trưởng cực kỳ tốt: log₂ của một triệu là 20, của một tỷ là 30. Thêm dữ liệu gấp một nghìn lần chỉ thêm mười bước.
def binary_search(arr, target):
"""arr phải đã sorted. Chia đôi khoảng tìm kiếm ở mọi vòng lặp."""
lo, hi = 0, len(arr) - 1
while lo <= hi: # khoảng: n, n/2, n/4, ... 1
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
if arr[mid] < target:
lo = mid + 1 # bỏ nguyên nửa dưới
else:
hi = mid - 1 # bỏ nguyên nửa trên
return -1 # O(log n) lần lặp
Các thành viên khác: insert/search trong balanced BST, push/pop trên binary heap, lũy thừa bằng bình phương liên tiếp, đếm số chữ số của một số.
O(n) — tuyến tính. Công việc tăng tỉ lệ thuận với input, đó là thứ bạn nhận được khi phải nhìn mọi phần tử đúng một lần. Với nhiều bài toán, đây là tối ưu, bởi bạn không thể quyết định được đáp án mà không đọc input: tìm phần tử lớn nhất của một array chưa sorted bắt buộc phải Ω(n), vì bất kỳ phần tử nào bạn bỏ qua đều có thể là phần tử lớn nhất.
def maximum(arr):
best = arr[0]
for x in arr: # chạm mọi phần tử đúng một lần
if x > best:
best = x
return best # O(n)
O(n log n) — tuyến tính-logarit. Thường phát sinh từ việc làm O(log n) công việc cho mỗi phần tử trong n phần tử, hoặc từ chia để trị chia đôi và làm công việc tuyến tính ở mỗi tầng. Đây là complexity của các thuật toán sắp xếp tổng quát tốt, và nó đủ gần tuyến tính để trong thực tế được coi là rẻ: với n = 10⁶, log₂ n chỉ là 20.
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
return merge(merge_sort(arr[:mid]), merge_sort(arr[mid:]))
# log n tầng đệ quy x O(n) công merge mỗi tầng = O(n log n)
O(n^k) — đa thức. k vòng lặp lồng nhau, mỗi vòng chạy n lần. Bậc hai (k = 2) là ngựa thồ của các thuật toán ngây thơ — so sánh mọi cặp — và là lớp complexity đầu tiên thật sự gây đau ở quy mô lớn. Bậc ba (k = 3) là phép nhân ma trận tiêu chuẩn và Floyd–Warshall tìm đường đi ngắn nhất mọi cặp. Thời gian đa thức là ranh giới hình thức của “khả thi” trong lý thuyết complexity, nhưng về mặt kỹ thuật thì bất cứ thứ gì vượt n³ thường đã bất khả thi rồi.
def bubble_sort(arr):
"""So sánh mọi cặp kề nhau, lặp đi lặp lại: O(n^2)."""
a = list(arr)
n = len(a)
for i in range(n): # n lượt
swapped = False
for j in range(n - i - 1): # tối đa n phép so sánh mỗi lượt
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
swapped = True
if not swapped: # thoát sớm làm BEST case thành O(n)
break
return a # worst và average: O(n^2)
def matrix_multiply(A, B):
"""Ba vòng lặp lồng nhau tiêu chuẩn: O(n^3)."""
n = len(A)
C = [[0] * n for _ in range(n)]
for i in range(n): # n
for j in range(n): # x n
for k in range(n): # x n
C[i][j] += A[i][k] * B[k][j]
return C # O(n^3)
O(2^n) — hàm mũ. Mỗi phần tử input thêm vào sẽ nhân đôi khối lượng công việc. Đây là dấu hiệu của những thuật toán phải xét mọi tập con, hoặc của đệ quy phân nhánh hai lần mà không memo. Nó chỉ dùng được với n rất nhỏ: khoảng 25 đến 30 phần tử là trần thực tế, và tại n = 60 thì bạn đã vượt quá số nano giây trôi qua kể từ thời khủng long.
def all_subsets(items):
"""Có 2^n tập con, nên chỉ riêng việc liệt kê chúng đã là O(2^n)."""
result = [[]]
for x in items: # mỗi phần tử nhân đôi kích thước kết quả
result += [subset + [x] for subset in result]
return result # O(2^n) time và O(2^n) space
def fib_naive(n):
"""Hai lời gọi đệ quy mỗi tầng, độ sâu n: khoảng 2^n lời gọi."""
if n < 2:
return n
return fib_naive(n - 1) + fib_naive(n - 2) # O(2^n) — thật ra là O(phi^n)
Hàm mũ thường là triệu chứng chứ không phải điều bắt buộc. fib_naive là hàm mũ thuần túy vì nó tính lại cùng những subproblem; memo nó lại là thành tuyến tính. Nhận ra các subproblem lặp lại và loại bỏ chúng là kỹ năng cốt lõi của quy hoạch động.
O(n!) — giai thừa. Số cách sắp xếp thứ tự n phần tử. Nó xuất hiện mỗi khi một thuật toán thử mọi hoán vị: TSP vét cạn, xếp lịch vét cạn, giải đố ngây thơ. Nó tăng nhanh hơn cả hàm mũ và không dùng được quá khoảng n = 11. 13! đã vượt sáu tỷ.
def permutations(items):
"""n! cách sắp xếp; sinh hết chúng là O(n * n!)."""
if len(items) <= 1:
return [list(items)]
out = []
for i in range(len(items)): # n lựa chọn cho phần tử đầu tiên
rest = items[:i] + items[i + 1:]
for p in permutations(rest): # x (n-1)! cách sắp xếp phần còn lại
out.append([items[i]] + p)
return out # O(n!)
def tsp_bruteforce(dist):
"""Thử mọi hành trình. n! ứng viên — đúng, và vô vọng quá n ~ 11."""
n = len(dist)
best = float("inf")
for order in permutations(list(range(1, n))):
tour = [0] + order + [0]
cost = sum(dist[tour[i]][tour[i + 1]] for i in range(len(tour) - 1))
best = min(best, cost)
return best
Tốc độ tăng trưởng thật sự quan trọng đến mức nào
Các lớp trừu tượng trở nên thuyết phục khi bạn đặt con số vào. Bảng này cho số bước xấp xỉ của từng lớp ở ba kích thước input.
| Complexity | n = 10 | n = 1000 | n = 1.000.000 |
|---|---|---|---|
O(1) | 1 | 1 | 1 |
O(log n) | 3 | 10 | 20 |
O(√n) | 3 | 32 | 1.000 |
O(n) | 10 | 1.000 | 1.000.000 |
O(n log n) | 33 | 9.966 | 19.931.569 |
O(n²) | 100 | 1.000.000 | 1012 |
O(n³) | 1.000 | 109 | 1018 |
O(2^n) | 1.024 | ~10301 | không mô tả nổi |
O(n!) | 3.628.800 | ~102568 | không mô tả nổi |
Cùng bảng đó quy đổi ra thời gian thực, giả định lạc quan 109 phép toán sơ cấp mỗi giây:
| Complexity | n = 10 | n = 1000 | n = 1.000.000 |
|---|---|---|---|
O(log n) | < 1 ns | < 1 ns | 20 ns |
O(n) | 10 ns | 1 µs | 1 ms |
O(n log n) | 33 ns | 10 µs | 20 ms |
O(n²) | 100 ns | 1 ms | 17 phút |
O(n³) | 1 µs | 1 giây | 31 năm |
O(2^n) | 1 µs | lâu hơn tuổi vũ trụ | — |
O(n!) | 3,6 ms | lâu hơn tuổi vũ trụ | — |
Câu hỏi ngược lại mới là câu bạn thật sự đặt ra trong competitive programming và trong hoạch định năng lực: cho khoảng một giây tính toán, complexity này xử lý được n lớn tới đâu?
| Complexity | n khả thi trong ~1 giây |
|---|---|
O(log n) | gần như không giới hạn |
O(n) | ~108 |
O(n log n) | ~106 – 107 |
O(n²) | ~104 |
O(n³) | ~500 |
O(2^n) | ~25 |
O(n!) | ~11 |
Đọc ngược bảng này thì nó thành một công cụ thiết kế. Nếu đề bài nói n ≤ 10⁵ thì lời giải O(n²) là 1010 phép toán và sẽ không qua được — bạn cần O(n log n) hoặc tốt hơn, điều này lập tức gợi ý tới sorting, heap, hash map hoặc binary search. Nếu n ≤ 20 thì liệt kê tập con theo hàm mũ là ổn và bạn nên dừng việc tìm cách gì thông minh hơn.
Best case, average case, worst case
Complexity không chỉ phụ thuộc vào kích thước input mà còn vào nội dung của nó. Với một n cho trước, có input dễ và có input khó, nên ta mô tả ba trường hợp:
- Best case — input rẻ nhất trong các input kích thước
n. Thường là con số ít hữu ích nhất: nó hay là một trường hợp giả tạo (array vốn đã sorted, target tìm thấy ngay lần dò đầu tiên) và nó không bao giờ cho bạn biết hệ thống có trụ nổi hay không. - Worst case — input đắt nhất trong các input kích thước
n. Đây là mặc định, và đúng là nên vậy: nó là một bảo đảm, nó là thứ mà SLO về latency và các hệ thống real-time cần, và input đối nghịch — một user có quyền chọn dữ liệu — biến “khó xảy ra” thành “mọi request”. - Average case — chi phí kỳ vọng trên một phân phối input nào đó. Phản ánh hành vi thông thường tốt hơn, nhưng nó buộc bạn phải nêu rõ phân phối, và phân phối trung thực thì thường không biết được.
| Thuật toán | Best | Average | Worst | Vì sao worst khác |
|---|---|---|---|---|
| Linear search | O(1) | O(n) | O(n) | target ở cuối hoặc không có |
| Binary search | O(1) | O(log n) | O(log n) | target trúng ngay điểm giữa đầu tiên |
| Insertion sort | O(n) | O(n²) | O(n²) | đã sorted vs sorted ngược |
| Bubble sort (có thoát sớm) | O(n) | O(n²) | O(n²) | một lượt sạch vs sorted ngược |
| Selection sort | O(n²) | O(n²) | O(n²) | luôn quét hết phần còn lại — không có best case |
| Merge sort | O(n log n) | O(n log n) | O(n log n) | luôn chia đôi bất kể nội dung |
| Quicksort | O(n log n) | O(n log n) | O(n²) | pivot luôn là min/max → phân hoạch cỡ 1 và n−1 |
| Heapsort | O(n log n) | O(n log n) | O(n log n) | hình dạng heap không phụ thuộc nội dung |
| Tra cứu hash table | O(1) | O(1) | O(n) | mọi key va chạm vào cùng một bucket |
| Tìm trong BST (mất cân bằng) | O(log n) | O(log n) | O(n) | chèn dữ liệu sorted → suy biến thành list |
| Tìm trong AVL / red-black | O(log n) | O(log n) | O(log n) | cân bằng lại giúp chặn chiều cao |
Hai dòng trong bảng đáng dừng lại, vì khoảng cách giữa average và worst không chỉ là lý thuyết — nó là một bề mặt tấn công.
Quicksort chọn một pivot và phân hoạch quanh nó. Với pivot tốt thì các phân hoạch cân bằng và hệ thức truy hồi là T(n) = 2T(n/2) + O(n) = O(n log n). Với pivot tệ nhất (luôn là phần tử nhỏ nhất hoặc lớn nhất) thì các phân hoạch có cỡ 0 và n−1, cho ra T(n) = T(n−1) + O(n) = O(n²). Một implementation ngây thơ luôn chọn arr[0] sẽ rơi vào worst case với input vốn đã sorted, một tình huống phổ biến đến mức đáng buồn trong thực tế. Chọn pivot ngẫu nhiên hoặc dùng median-of-three biến worst case từ chuyện thường ngày thành chuyện hầu như không thể xảy ra.
Hash table tụt xuống O(n) khi mọi key rơi vào cùng một bucket. Điều này không phải giả thuyết: hash flooding là một kỹ thuật từ chối dịch vụ có thật, trong đó kẻ tấn công gửi lên những key được chọn sao cho va chạm nhau, biến mọi lần chèn O(1) thành một lần quét O(n) và hạ gục server chỉ với lưu lượng khiêm tốn. Cách khắc phục — hash seed ngẫu nhiên theo từng process — nay đã là tiêu chuẩn trong Python, Ruby và JVM (./07-hash-tables.md).
Một làm rõ then chốt: best/average/worst là một trục hoàn toàn tách biệt với O/Ω/θ. Cái thứ nhất nói bạn đang phân tích input nào; cái thứ hai nói bạn đang phát biểu loại cận nào. Bạn có thể kết hợp chúng thoải mái và việc đó có ý nghĩa thật sự:
- “Worst case của quicksort là
θ(n²)” — một cận chặt trên input tệ nhất. - “Best case của quicksort là
Ω(n log n)” — ngay cả input may mắn nhất cũng cần ít nhất chừng đó. - “Insertion sort là
O(n²)” — một cận trên bao phủ mọi input.
Nói “average case của Big-O” là một lỗi phạm trù; hai ý tưởng này không ghép với nhau theo kiểu đó.
Amortized analysis
Có những thao tác thường rẻ nhưng thỉnh thoảng cực đắt. Chỉ báo cáo worst case cho một thao tác đơn lẻ sẽ gây hiểu lầm nếu trường hợp đắt đỏ đó không thể xảy ra thường xuyên. Amortized analysis đo chi phí trung bình cho mỗi thao tác trên một chuỗi thao tác tệ nhất.
Ví dụ kinh điển là dynamic array đang lớn dần. Append bình thường là O(1) — ghi vào ô trống kế tiếp. Nhưng khi array đầy, nó phải cấp phát một khối lớn hơn và copy toàn bộ sang: O(n). Vậy một lần append là O(n) ở worst case. Thế thì append có phải là thao tác đắt không?
Không — vì bước đắt đỏ đó trả tiền cho những bước rẻ theo sau. Nếu dung lượng nhân đôi ở mỗi lần resize thì bắt đầu từ dung lượng 1 và append n phần tử, các lần resize sẽ copy tổng cộng
1 + 2 + 4 + 8 + ... + n/2 + n < 2n phần tử
Cấp số nhân đó có tổng nhỏ hơn 2n, nên n lần append tốn tổng cộng O(n), tức là O(1) amortized cho mỗi lần append. Việc nhân đôi là thiết yếu: nếu thay bằng tăng thêm một lượng cố định k thì sẽ cần n/k lần resize copy k, 2k, 3k, …, n phần tử — một tổng O(n²/k), khiến mỗi lần append thành O(n) amortized. Đây chính xác là lý do list trong Python, vector trong C++, và slice trong Go đều lớn lên theo cấp số nhân.
class DynamicArray:
"""Một dynamic array xây trên vùng lưu trữ kích thước cố định, để lộ ra bước
resize mà list của Python giấu đi. Append là O(1) amortized."""
def __init__(self):
self._capacity = 1
self._size = 0
self._data = [None] * self._capacity
def append(self, x):
if self._size == self._capacity: # hiếm: chỉ xảy ra log2(n) lần tổng cộng
self._resize(self._capacity * 2)
self._data[self._size] = x # thường gặp: O(1)
self._size += 1
def _resize(self, new_capacity):
bigger = [None] * new_capacity # cấp phát O(n)
for i in range(self._size): # copy O(n)
bigger[i] = self._data[i]
self._data = bigger
self._capacity = new_capacity
def __getitem__(self, i):
if not 0 <= i < self._size:
raise IndexError(i)
return self._data[i] # luôn là O(1)
def __len__(self):
return self._size
a = DynamicArray()
for i in range(1000): # 1000 lần append, ~10 lần resize,
a.append(i) # tổng cộng < 2000 lần copy phần tử
assert len(a) == 1000 and a[999] == 999
Ba kỹ thuật tiêu chuẩn để chứng minh cận amortized:
- Phương pháp tổng hợp (aggregate) — chặn tổng chi phí của
nthao tác, rồi chia chon. Lập luận cấp số nhân ở trên chính là phương pháp này. - Phương pháp kế toán (banker’s) — tính cho mỗi thao tác một mức giá amortized do ta đặt ra, để dành phần dư làm tín dụng, rồi tiêu tín dụng đó vào các thao tác đắt. Tính 3 đơn vị cho mỗi lần append: 1 để ghi phần tử, 1 để dành cho việc copy nó ở lần resize tới, 1 để dành cho việc copy một phần tử cũ đã hết tín dụng. Số dư ngân hàng không bao giờ âm, nên tổng chi phí thật nhiều nhất là
3n. - Phương pháp thế năng (potential) — định nghĩa một hàm thế năng
Φtrên trạng thái của cấu trúc và định nghĩa chi phí amortized bằng chi phí thật cộngΔΦ. Với dynamic array,Φ = 2·size − capacitylà đủ dùng: nó tăng lên trong các lần append rẻ, và nó vừa đủ lớn để trả cho lần copy khi resize xảy ra.
Amortized không giống average case. Average case mang tính xác suất — nó phụ thuộc vào giả định về phân phối input, và một user xui xẻo có thể phá vỡ nó. Amortized là một bảo đảm worst-case tất định trên cả chuỗi: không kẻ nào dựng được n lần append tốn hơn O(n) tổng cộng. Đó là một khẳng định mạnh hơn nhiều, và đó là lý do “amortized O(1)” an toàn để dựa vào trong một ngân sách latency, còn “average O(1)” thì cần có kế hoạch cho phần đuôi phân phối.
Chỗ bạn gặp cận amortized trong thực tế:
| Thao tác | Amortized | Worst case cho một lần | Cơ chế |
|---|---|---|---|
| Append dynamic array | O(1) | O(n) | nhân đôi dung lượng |
| Insert vào hash table | O(1) | O(n) | rehash khi vượt load factor |
find của Union-Find | O(α(n)) | O(log n) | path compression + union by rank |
| Truy cập splay tree | O(log n) | O(n) | các phép xoay tái cấu trúc cây |
| Tăng một bộ đếm nhị phân | O(1) mỗi lần tăng | O(log n) | hầu hết lần tăng chỉ lật một bit |
α(n) là hàm Ackermann ngược, có giá trị nhiều nhất là 4 với mọi n vừa trong vũ trụ quan sát được — coi như hằng số (./16-disjoint-set-union-find.md).
Chỗ mô hình thôi không còn dự đoán được thực tế
Phân tích complexity là một mô hình, và mọi mô hình đều có miền hiệu lực. Bốn thứ nó cố tình bỏ qua, và mỗi thứ đều có thể áp đảo các phép đo thực tế.
1. Hằng số quan trọng khi n nhỏ. Ký hiệu O bỏ đi các hệ số hằng, nhưng cái máy thì không. Insertion sort là θ(n²) còn merge sort là θ(n log n), vậy mà insertion sort lại nhanh hơn với array khoảng 10 đến 50 phần tử, vì hệ số hằng của nó rất nhỏ: không cấp phát, không đệ quy, không copy, và một vòng lặp trong gọn gàng với locality hoàn hảo. Đây không phải chuyện lạ đời — các thuật toán sắp xếp production khai thác đúng điều này. Timsort (sorted của Python, Arrays.sort cho object của Java) chạy insertion sort trên các đoạn nhỏ hơn một ngưỡng rồi merge kết quả. Introsort (std::sort của C++) chạy quicksort, chuyển sang heapsort khi đệ quy đi quá sâu để tránh trường hợp O(n²), và kết thúc bằng insertion sort trên phần dư nhỏ. Lý thuyết chọn thuật toán tiệm cận; hằng số chọn ngưỡng cắt.
2. Phân cấp memory không phẳng. Mô hình RAM từ ./01-programming-fundamentals-and-pseudocode.md tính O(1) cho mọi lần truy cập memory. Phần cứng thật tính giá khác nhau một trời một vực:
| Truy cập | Độ trễ xấp xỉ | Tương đối |
|---|---|---|
| L1 cache | ~1 ns | 1× |
| L2 cache | ~4 ns | 4× |
| L3 cache | ~15 ns | 15× |
| Main memory (RAM) | ~100 ns | 100× |
| NVMe SSD | ~100 µs | 100.000× |
| Đĩa từ (seek) | ~10 ms | 10.000.000× |
Duyệt một array và duyệt một linked list đều là θ(n), nhưng array chảy tuần tự qua các cache line còn linked list phải đuổi theo pointer tới những địa chỉ ngẫu nhiên, gần như trượt cache mọi lần. Khoảng cách đo được thường xuyên là 5–10×. Đây cũng là toàn bộ lý do B-tree tồn tại: khi đơn vị chi phí là một page trên đĩa thay vì một phép so sánh, bạn muốn cây thấp và rộng, và log₂ n so với log₂₅₆ n là khác biệt giữa 30 lần đọc và 4 lần (../../postgresql-dba/vi/08-indexing-strategies.md).
3. Những điều hư cấu khác của mô hình RAM. Nó giả định mọi phép số học tốn một đơn vị bất kể độ lớn toán hạng — sai với số nguyên độ chính xác tùy ý, nơi nhân hai số d chữ số không phải O(1). Nó giả định một bộ xử lý duy nhất, nên chẳng nói gì về tăng tốc song song. Nó bỏ qua branch prediction, SIMD, chi phí cấp phát, và các lần dừng của garbage collector. Không điều nào trong số đó làm mô hình sai; chúng chỉ làm nó thành một mô hình bậc nhất mà phần sai số còn lại bạn xử lý bằng cách đo đạc.
4. Tốt hơn về mặt tiệm cận có thể vô dụng về mặt thực tế. Những thuật toán mà lợi thế chỉ hiện ra ở kích thước input lớn hơn bất cứ thứ gì từng tồn tại được gọi là galactic algorithm. Phép nhân ma trận có các cận đã biết quanh O(n^2.37), nhưng hệ số hằng lớn khủng khiếp đến mức phương pháp O(n³) trong sách giáo khoa — hoặc phương pháp Strassen O(n^2.807) — thắng với mọi ma trận mà con người thực sự nhân. Câu hỏi đúng không bao giờ là “cái nào có số mũ nhỏ hơn” mà là “cái nào nhanh hơn ở n của tôi”.
Một điểm thực tế liên quan: trong thực tế n thường bị chặn. Nếu function của bạn sắp xếp các món trong giỏ hàng thì n < 100 mãi mãi, và khác biệt giữa O(n²) và O(n log n) là con số không. Bỏ cả buổi chiều ra thay thuật toán sắp xếp còn tệ hơn cả lãng phí — nó thêm rủi ro mà chẳng được lợi gì. Phân tích complexity cho bạn biết vách đá ở đâu; việc của bạn là biết input của mình có thể bước hụt xuống đó hay không.
Best Practices
- Ghi complexity của mọi function không tầm thường bạn viết, trong comment hoặc docstring. Việc đó tốn vài giây, nó buộc bạn thật sự kiểm tra lại, và nó là mẩu tài liệu hữu ích nhất cho người sau này gọi function đó trong một vòng lặp.
- Mặc định phân tích worst case. Đó là một bảo đảm chứ không phải một hy vọng, và đó là thứ mà kẻ tấn công hoặc một phân phối dữ liệu production xui xẻo sẽ tìm ra. Hãy nêu average như thông tin bổ sung, đừng bao giờ đặt nó làm tiêu đề.
- Nói
θkhi bạn có ý làθ. “Bubble sort làO(n²)” thì đúng nhưng yếu; “bubble sort làθ(n²)ở worst và average case,θ(n)với input đã sorted” là toàn cảnh trong một dòng. - Tính ra con số thật trước khi tối ưu. Cắm
nthật của bạn vào bảng tăng trưởng.O(n²)tạin = 200là 40.000 phép toán — 40 micro giây, chẳng phải vấn đề. Lớp complexity mô tả sự co giãn; chỉ có phép tính số học mới nói cho bạn biết đoạn code này có chậm hay không. - Săn tìm những chỗ vô tình bậc hai. Chúng là bug hiệu năng thực tế phổ biến nhất và vô hình trong các test nhỏ. Những nghi phạm quen thuộc: một phép kiểm tra
intuyến tính đặt trong vòng lặp,list.pop(0)hoặcinsert(0, …)trong vòng lặp, nối chuỗi bằng+=trong vòng lặp, một truy vấn ORM đặt trong vòng lặp (bài toán N+1), hoặclist.remove()trong vòng lặp. - Đừng quên space, kể cả call stack. Một lời giải đệ quy sâu
ntầng dùngO(n)stack — và giới hạn đệ quy mặc định 1000 của Python sẽ chặn bạn từ rất lâu trước khi memory kịp cạn. Hãy chuyển đệ quy sâu thành vòng lặp với một stack tường minh. - Ưu tiên thuật toán mà bạn sống chung được với worst case của nó. Giữa quicksort (
O(n log n)trung bình,O(n²)worst) và heapsort (O(n log n)luôn luôn), hãy chọn dựa trên việc một cú vọt latency ở phần đuôi có chấp nhận được không. Nếu đó là request hướng người dùng với SLO cứng thì cận được bảo đảm đáng giá hơn cái hằng số chậm hơn. - Ngẫu nhiên hóa hoặc cân bằng lại khi worst case có thể chạm tới. Pivot ngẫu nhiên cho quicksort, hash có seed cho hash table, cây tự cân bằng thay vì BST thường. Những thứ này biến “tệ với input đối nghịch hoặc đã sorted” thành “tệ với xác suất không đáng kể”.
- Đo đạc sau khi phân tích. Phân tích chọn thuật toán; profiling tìm ra các hệ số hằng, cache miss, và lượng cấp phát rác mà phân tích đã trừu tượng hóa đi. Đừng bao giờ tối ưu theo linh cảm — hãy profile trước, và xác nhận cải thiện bằng benchmark trên kích thước input thực tế.
- Thuộc lòng complexity của standard library. Phần lớn các chỗ vô tình bậc hai đến từ việc tưởng một built-in là
O(1)trong khi nó làO(n). Bảng TimeComplexity của Python đáng đọc kỹ một lần cho tử tế.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Big-O Cheat Sheet
- Big O notation — Wikipedia
- Time complexity — Wikipedia
- Space complexity — Wikipedia
- Amortized analysis — Wikipedia
- Master theorem (analysis of algorithms) — Wikipedia
- Galactic algorithm — Wikipedia
- CLRS — Introduction to Algorithms, Chapters 3 (Growth of Functions), 4 (Divide-and-Conquer), 17 (Amortized Analysis)
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- cp-algorithms — Sieve of Eratosthenes
- Python Wiki — TimeComplexity of built-in types
- Python Documentation —
timeitfor measuring small code snippets
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
Algorithmic complexity is the study of what an algorithm costs as its input grows — how much time it takes and how much memory it consumes, expressed as a function of the input size n. It is the single most transferable skill in this entire roadmap. Structures come and go, languages change, but the ability to look at a loop and say “that is O(n²) and it will not survive production data” is permanent.
The reason we measure growth rather than seconds is that seconds are not a property of the algorithm. Run the same code on a laptop and on a server, in Python and in C, with a cold cache and a warm one, and you get numbers that differ by a factor of a hundred — none of which tell you anything about the algorithm itself. What is a property of the algorithm is how the cost scales: double the input, does the work double, quadruple, or stay the same? That question has a machine-independent answer, and that answer is what decides whether your code works at scale.
The stakes are easy to underestimate until you put numbers on them. An O(n²) algorithm on n = 1000 does a million operations — instant. The same algorithm on n = 1,000,000 does 1012 operations, which at a billion operations per second is roughly seventeen minutes. Meanwhile an O(n log n) algorithm on that same million-element input does about 2 × 107 operations: twenty milliseconds. No compiler flag, no faster CPU, and no amount of rewriting the inner loop closes a gap of fifty thousand times. That gap is chosen when you choose the algorithm, and complexity analysis is how you see it before you ship.
This note covers what to measure (time versus space), how to derive complexity from code, the runtimes you will meet in practice, the formal notation (O, Ω, θ) used to state results, the best/average/worst-case distinction, amortized analysis, and — importantly — where the whole framework stops being a good predictor of real wall-clock time.
Fundamentals
Why not just time it
Measuring is genuinely useful and you should do it, but it does not replace analysis, for three reasons:
- A measurement is a single point; complexity is the curve. Timing your function on the 500-row test fixture tells you nothing about the 5,000,000-row production table. The whole failure mode of quadratic code is that it looks fine in testing.
- Measurements are contaminated. Interpreter overhead, JIT warm-up, garbage collection, cache state, CPU frequency scaling, and the noisy neighbour on your cloud instance all move the number. Analysis is not affected by any of them.
- Analysis works before the code exists. You can compare two designs on a whiteboard. You cannot benchmark something you have not written.
The correct workflow is both: analyse to choose the algorithm, measure to catch the things analysis abstracts away (constants, cache, allocator behaviour). Analysis without measurement ships galactic algorithms; measurement without analysis ships code that dies at 10× scale.
Time complexity versus space complexity
Time complexity counts the number of elementary operations an algorithm performs as a function of input size. Space complexity counts the memory it needs, likewise as a function of input size.
Space complexity has a subtlety worth pinning down. There are two things you might mean:
- Total space — including the input itself. Any algorithm that reads an array of
nitems uses at leastO(n)total space. - Auxiliary space — the extra memory the algorithm allocates beyond the input. This is almost always the interesting number, and when someone says “merge sort is
O(n)space and quicksort isO(log n)space,” they mean auxiliary.
Auxiliary space includes anything you might forget: the recursion call stack (one frame per active call, so a recursion d deep costs O(d) even if each frame is tiny), temporary arrays, memoization tables, and the output if you build a new one rather than mutating in place.
| Algorithm | Time | Auxiliary space | Why |
|---|---|---|---|
| Iterative sum of an array | O(n) | O(1) | one accumulator |
| Binary search (iterative) | O(log n) | O(1) | two indices |
| Binary search (recursive) | O(log n) | O(log n) | one stack frame per level |
| Merge sort | O(n log n) | O(n) | the merge buffer |
| Quicksort (in-place) | O(n log n) avg | O(log n) avg | recursion stack only |
| Heapsort | O(n log n) | O(1) | sorts inside the array |
Counting sort (range k) | O(n + k) | O(k) | the count array |
| Naive recursive Fibonacci | O(2^n) | O(n) | stack depth is n |
| Memoized Fibonacci | O(n) | O(n) | the memo table |
| Iterative Fibonacci | O(n) | O(1) | two variables |
The space–time trade-off
Those last three rows are the trade-off in miniature. You can nearly always buy time with memory, and sometimes buy memory with time:
# O(2^n) time, O(n) space — recomputes the same subproblems exponentially often.
def fib_naive(n):
if n < 2:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
# O(n) time, O(n) space — spend memory to remember answers you already computed.
def fib_memo(n, memo=None):
if memo is None:
memo = {}
if n < 2:
return n
if n not in memo:
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
# O(n) time, O(1) space — notice you only ever need the last two values.
def fib_iter(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
fib_naive(35) takes seconds; fib_memo(35) and fib_iter(35) are instant. This is the whole idea behind dynamic programming. Other everyday instances of the trade-off: a hash table deliberately wastes memory (staying partly empty) to keep lookups O(1); a database index stores a whole extra sorted structure so that reads avoid a full scan; a cache stores redundant copies so a request skips recomputation. Precomputation, memoization, indexing, and caching are all the same move.
The trade goes the other way too. Compressing data trades CPU time for smaller memory footprint. In-place sorting trades a slightly more complex algorithm for O(1) auxiliary space. Which direction you want depends on which resource is scarce — and on embedded hardware or in a tight memory budget, space wins.
How to calculate complexity from code
The mechanical procedure is: count the operations as a function of n, then simplify by keeping only the fastest-growing term and dropping constant factors. Four rules do almost all the work.
Rule 1 — constant work is O(1). Arithmetic, comparisons, assignments, array indexing, and hash lookups each count as one step, regardless of the values involved.
Rule 2 — sequential blocks add. If block A is O(f(n)) and block B is O(g(n)), running A then B is O(f(n) + g(n)), which simplifies to O(max(f, g)).
Rule 3 — nested loops multiply. A loop running f(n) times whose body costs g(n) is O(f(n) · g(n)).
Rule 4 — drop constants and lower-order terms. 3n² + 5n + 100 is O(n²). Constants are dropped because they are machine-specific; lower-order terms are dropped because for large n they are invisible next to the leading term. At n = 10⁶, n² is 1012 and 5n is 5 × 106 — the lower term contributes 0.0005% of the total.
Applied to real code:
# --- O(1): work independent of n ---
def get_middle(arr):
return arr[len(arr) // 2] # one index computation, one access
# --- O(n): one pass ---
def total(arr):
s = 0 # 1 step
for x in arr: # n iterations
s += x # 1 step each -> n steps
return s # 1 step
# 2n + 2 -> O(n)
# --- O(n): sequential loops ADD, they do not multiply ---
def sum_and_max(arr):
s = 0
for x in arr: # n
s += x
m = arr[0]
for x in arr: # + n
m = max(m, x)
return s, m # 2n -> O(n)
# --- O(n^2): nested loops MULTIPLY ---
def has_duplicate_pair(arr, target):
for i in range(len(arr)): # n
for j in range(len(arr)): # x n
if i != j and arr[i] + arr[j] == target:
return True
return False # n*n -> O(n^2)
# --- Still O(n^2): a triangular loop is n(n-1)/2, and constants drop ---
def all_pairs(arr):
pairs = []
for i in range(len(arr)): # n
for j in range(i + 1, len(arr)): # n-i-1 on average n/2
pairs.append((arr[i], arr[j]))
return pairs # n(n-1)/2 -> O(n^2)
# --- O(log n): the loop variable is MULTIPLIED, not incremented ---
def count_halvings(n):
steps = 0
while n > 1: # n -> n/2 -> n/4 -> ... -> 1
n //= 2
steps += 1
return steps # log2(n) iterations -> O(log n)
# --- O(n log n): a linear loop containing a logarithmic one ---
def total_halvings(arr):
steps = 0
for x in arr: # n
steps += count_halvings(x) # x O(log x)
return steps # O(n log n)
# --- O(n): NOT O(n^2). The inner loop total is bounded, not repeated. ---
def longest_run(arr):
best, i = 0, 0
while i < len(arr): # looks nested...
j = i
while j < len(arr) and arr[j] == arr[i]:
j += 1 # ...but j never moves backwards,
best = max(best, j - i) # so the inner loop advances a total
i = j # of n times across the WHOLE run.
return best # O(n)
That last example is the one people get wrong most often. Nesting in the syntax is not the same as multiplication in the cost. The right question is never “how deep is the nesting” but “how many times does this line execute in total, over the entire run”.
A second shape worth recognising is the harmonic loop, which looks quadratic and is not:
# Sieve of Eratosthenes: the inner loop runs n/2 + n/3 + n/5 + ... times.
# That sum is n * (sum of reciprocals of primes < n) = O(n log log n).
def primes_up_to(n):
is_prime = [True] * (n + 1)
is_prime[0] = is_prime[1] = False
p = 2
while p * p <= n: # outer loop: O(sqrt(n)) values of p
if is_prime[p]:
for multiple in range(p * p, n + 1, p): # n/p steps
is_prime[multiple] = False
p += 1
return [i for i, prime in enumerate(is_prime) if prime]
# total: O(n log log n), effectively linear
Here is the summary table to pattern-match against:
| Code shape | Complexity |
|---|---|
| Fixed number of statements, no loop | O(1) |
One loop over n items | O(n) |
Two sequential loops over n | O(n) — they add |
Loop inside a loop, both over n | O(n²) |
Triangular nested loop (j starts at i) | O(n²) — constant factor ½ drops |
Three nested loops over n | O(n³) |
while n > 1: n //= 2 | O(log n) |
Loop over n with a binary search or heap op inside | O(n log n) |
| Two pointers that only move forward | O(n) — total advancement is bounded |
Sliding window over n | O(n) — same reason (./23-two-pointers-and-sliding-window.md) |
Recursion splitting into 2 halves, O(n) merge | O(n log n) |
Recursion splitting into 2 halves, O(1) work | O(n) |
Recursion making 2 calls on n-1 | O(2^n) |
Enumerating all subsets of n items | O(2^n) |
Enumerating all permutations of n items | O(n!) |
Complexity of recursive code
Loops you count; recursion you turn into a recurrence relation — an equation expressing the cost of size n in terms of the cost of smaller inputs — and then solve.
def merge_sort(arr):
if len(arr) <= 1: # base case: O(1)
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # T(n/2)
right = merge_sort(arr[mid:]) # T(n/2)
return merge(left, right) # O(n)
def merge(a, b):
out, i, j = [], 0, 0
while i < len(a) and j < len(b): # each element moved exactly once
if a[i] <= b[j]:
out.append(a[i]); i += 1
else:
out.append(b[j]); j += 1
out.extend(a[i:]); out.extend(b[j:])
return out # O(len(a) + len(b))
The recurrence is T(n) = 2T(n/2) + O(n), with T(1) = O(1). Drawing the recursion tree: each level does O(n) total work (the merges at that level cover every element once), and there are log₂ n levels because the size halves each time. So T(n) = O(n log n).
The Master Theorem turns this into a lookup for recurrences of the form T(n) = a·T(n/b) + f(n):
| Recurrence | Solution | Example |
|---|---|---|
T(n) = T(n/2) + O(1) | O(log n) | binary search |
T(n) = T(n/2) + O(n) | O(n) | quickselect (average) |
T(n) = 2T(n/2) + O(1) | O(n) | tree traversal, heapify |
T(n) = 2T(n/2) + O(n) | O(n log n) | merge sort, quicksort (average) |
T(n) = 2T(n/2) + O(n²) | O(n²) | the top-level work dominates |
T(n) = T(n-1) + O(1) | O(n) | linear recursion, sum of a list |
T(n) = T(n-1) + O(n) | O(n²) | quicksort worst case, selection sort |
T(n) = 2T(n-1) + O(1) | O(2^n) | naive Fibonacci, Towers of Hanoi |
T(n) = n·T(n-1) + O(1) | O(n!) | generating all permutations |
The derivation is developed properly in ./19-recursion-and-divide-and-conquer.md.
Key Concepts
Asymptotic notation
Asymptotic notation describes the limiting behaviour of a function as its argument grows, discarding constant factors and lower-order terms. Three notations carry almost all the weight, and they say different things — a distinction that people routinely blur.
Big-O — upper bound. f(n) = O(g(n)) if there exist positive constants c and n₀ such that
0 ≤ f(n) ≤ c · g(n) for all n ≥ n₀
In words: beyond some input size, f grows no faster than g, up to a constant factor. Big-O is a ceiling. It is the notation you see most because in practice you care most about “how bad can this get”.
Big-Ω — lower bound. f(n) = Ω(g(n)) if there exist positive constants c and n₀ such that
0 ≤ c · g(n) ≤ f(n) for all n ≥ n₀
Beyond some input size, f grows at least as fast as g. Big-Ω is a floor. Its most important use is stating limits on problems rather than algorithms: “any comparison-based sort requires Ω(n log n) comparisons” is a statement that no algorithm, present or future, can beat that bound — which is what makes merge sort and heapsort provably optimal in that model (./08-sorting-algorithms.md).
Big-θ — tight bound. f(n) = θ(g(n)) if there exist positive constants c₁, c₂, and n₀ such that
0 ≤ c₁ · g(n) ≤ f(n) ≤ c₂ · g(n) for all n ≥ n₀
Equivalently, θ(g) = O(g) ∩ Ω(g): f is sandwiched by g from both sides, so g is exactly the growth rate. This is usually the honest description of an algorithm’s cost, and it is what most people mean when they say Big-O.
Two more, used less often but worth recognising:
- little-o —
f(n) = o(g(n))meansfgrows strictly slower: the bound holds for every positivec, not just some.n = o(n²)is true;n² = o(n²)is false. - little-ω — the strict version of
Ω.n² = ω(n).
| Notation | Meaning | Analogy | Typical use |
|---|---|---|---|
O(g) | grows no faster than g | ≤ | ”this algorithm costs at most…” |
Ω(g) | grows at least as fast as g | ≥ | ”this problem requires at least…” |
θ(g) | grows exactly like g | = | ”this is the true growth rate” |
o(g) | grows strictly slower than g | < | separating complexity classes |
ω(g) | grows strictly faster than g | > | separating complexity classes |
A worked proof, because the definitions are easier to trust once you have used them once. Claim: 3n² + 5n + 2 = θ(n²).
- Upper bound. For
n ≥ 1we haven ≤ n²and1 ≤ n², so3n² + 5n + 2 ≤ 3n² + 5n² + 2n² = 10n². Takec₂ = 10,n₀ = 1. HenceO(n²). - Lower bound. For
n ≥ 1,3n² + 5n + 2 ≥ 3n². Takec₁ = 3,n₀ = 1. HenceΩ(n²). - Both hold, so
θ(n²). ∎
Two notational traps. First, the = in f(n) = O(g(n)) is an abuse of notation — O(g(n)) is a set of functions, and the honest statement is f(n) ∈ O(g(n)). The consequence is that the relation is not symmetric: you may write f(n) = O(n²) but never O(n²) = f(n).
Second, Big-O is an upper bound, not a promise of tightness. Binary search is O(log n), and it is also — perfectly correctly — O(n), O(n²), and O(2^n), because all of those are valid ceilings. Saying “binary search is O(n²)” is true and useless. When someone states a complexity they almost always intend the tightest bound they know, i.e. θ; the convention is harmless as long as you remember that O alone does not forbid the algorithm from being faster.
The common runtimes
| Class | Name | Doubling n does what to the work? | Canonical example |
|---|---|---|---|
O(1) | Constant | nothing | array index, hash lookup |
O(log n) | Logarithmic | adds one step | binary search |
O(n) | Linear | doubles | linear scan |
O(n log n) | Linearithmic | slightly more than doubles | merge sort |
O(n²), O(n³), O(n^k) | Polynomial | ×4, ×8, ×2^k | bubble sort, matrix multiply |
O(2^n) | Exponential | squares it | subset enumeration |
O(n!) | Factorial | unspeakable | brute-force TSP |
O(1) — constant. The running time is independent of input size. Accessing arr[i] costs the same whether the array holds ten elements or ten million, because the address is computed arithmetically rather than searched for. Note that “constant” does not mean “fast” — a constant-time operation could involve a thousand instructions; it means the count does not grow with n.
def get(arr, i):
return arr[i] # address = base + i*size: O(1)
def is_even(n):
return n % 2 == 0 # O(1)
O(log n) — logarithmic. The algorithm eliminates a constant fraction of the remaining input at each step, so the number of steps is the number of times you can halve n before reaching 1. This is an extraordinarily good growth rate: log₂ of a million is 20, and of a billion is 30. Adding a thousand times more data adds ten steps.
def binary_search(arr, target):
"""arr must be sorted. Halves the search range every iteration."""
lo, hi = 0, len(arr) - 1
while lo <= hi: # range: n, n/2, n/4, ... 1
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
if arr[mid] < target:
lo = mid + 1 # discard the whole lower half
else:
hi = mid - 1 # discard the whole upper half
return -1 # O(log n) iterations
Other members: insert/search in a balanced BST, push/pop on a binary heap, exponentiation by squaring, finding the number of digits in a number.
O(n) — linear. The work grows in direct proportion to the input, which is what you get when you must look at every element once. For many problems this is optimal, because you cannot decide the answer without reading the input: finding the maximum of an unsorted array requires Ω(n), since any element you skip could have been the maximum.
def maximum(arr):
best = arr[0]
for x in arr: # touches every element exactly once
if x > best:
best = x
return best # O(n)
O(n log n) — linearithmic. Typically arises from doing O(log n) work for each of n elements, or from divide-and-conquer that splits in half and does linear work per level. This is the complexity of the good general-purpose sorts, and it is close enough to linear that in practice it is treated as cheap: for n = 10⁶, log₂ n is only 20.
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
return merge(merge_sort(arr[:mid]), merge_sort(arr[mid:]))
# log n levels of recursion x O(n) merging per level = O(n log n)
O(n^k) — polynomial. k nested loops each running n times. Quadratic (k = 2) is the workhorse of naive algorithms — compare every pair — and it is the first complexity class that genuinely hurts at scale. Cubic (k = 3) is standard matrix multiplication and Floyd–Warshall all-pairs shortest paths. Polynomial time is the formal boundary of “tractable” in complexity theory, but in engineering terms anything past n³ is usually already impractical.
def bubble_sort(arr):
"""Compares every adjacent pair, repeatedly: O(n^2)."""
a = list(arr)
n = len(a)
for i in range(n): # n passes
swapped = False
for j in range(n - i - 1): # up to n comparisons each
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
swapped = True
if not swapped: # early exit makes the BEST case O(n)
break
return a # worst and average: O(n^2)
def matrix_multiply(A, B):
"""Standard triple loop: O(n^3)."""
n = len(A)
C = [[0] * n for _ in range(n)]
for i in range(n): # n
for j in range(n): # x n
for k in range(n): # x n
C[i][j] += A[i][k] * B[k][j]
return C # O(n^3)
O(2^n) — exponential. Every additional input element doubles the work. This is the signature of algorithms that must consider every subset, or of recursion that branches twice without memoization. It is usable only for tiny n: around 25 to 30 elements is the practical ceiling, and at n = 60 you are past the number of nanoseconds since the dinosaurs.
def all_subsets(items):
"""There are 2^n subsets, so merely listing them is O(2^n)."""
result = [[]]
for x in items: # each item doubles the result size
result += [subset + [x] for subset in result]
return result # O(2^n) time and O(2^n) space
def fib_naive(n):
"""Two recursive calls per level, depth n: about 2^n calls."""
if n < 2:
return n
return fib_naive(n - 1) + fib_naive(n - 2) # O(2^n) — actually O(phi^n)
Exponential is often a symptom rather than a necessity. fib_naive is exponential purely because it recomputes the same subproblems; memoizing it makes it linear. Recognising redundant subproblems and eliminating them is the core skill of dynamic programming.
O(n!) — factorial. The number of orderings of n items. This appears whenever an algorithm tries every permutation: brute-force travelling salesman, brute-force scheduling, naive puzzle solving. It grows faster than exponential and is unusable beyond about n = 11. 13! already exceeds six billion.
def permutations(items):
"""n! orderings; generating them all is O(n * n!)."""
if len(items) <= 1:
return [list(items)]
out = []
for i in range(len(items)): # n choices for the first element
rest = items[:i] + items[i + 1:]
for p in permutations(rest): # x (n-1)! orderings of the rest
out.append([items[i]] + p)
return out # O(n!)
def tsp_bruteforce(dist):
"""Try every tour. n! candidates — correct, and hopeless past n ~ 11."""
n = len(dist)
best = float("inf")
for order in permutations(list(range(1, n))):
tour = [0] + order + [0]
cost = sum(dist[tour[i]][tour[i + 1]] for i in range(len(tour) - 1))
best = min(best, cost)
return best
How much the growth rate actually matters
Abstract classes become persuasive when you put numbers in them. This table gives the approximate step count for each class at three input sizes.
| Complexity | n = 10 | n = 1000 | n = 1,000,000 |
|---|---|---|---|
O(1) | 1 | 1 | 1 |
O(log n) | 3 | 10 | 20 |
O(√n) | 3 | 32 | 1,000 |
O(n) | 10 | 1,000 | 1,000,000 |
O(n log n) | 33 | 9,966 | 19,931,569 |
O(n²) | 100 | 1,000,000 | 1012 |
O(n³) | 1,000 | 109 | 1018 |
O(2^n) | 1,024 | ~10301 | beyond description |
O(n!) | 3,628,800 | ~102568 | beyond description |
The same table converted to wall-clock time, assuming an optimistic 109 elementary operations per second:
| Complexity | n = 10 | n = 1000 | n = 1,000,000 |
|---|---|---|---|
O(log n) | < 1 ns | < 1 ns | 20 ns |
O(n) | 10 ns | 1 µs | 1 ms |
O(n log n) | 33 ns | 10 µs | 20 ms |
O(n²) | 100 ns | 1 ms | 17 minutes |
O(n³) | 1 µs | 1 second | 31 years |
O(2^n) | 1 µs | longer than the age of the universe | — |
O(n!) | 3.6 ms | longer than the age of the universe | — |
The inverse question is the one you actually ask in competitive programming and in capacity planning: given about a second of compute, how large an n can this complexity handle?
| Complexity | Feasible n in ~1 second |
|---|---|
O(log n) | effectively unbounded |
O(n) | ~108 |
O(n log n) | ~106 – 107 |
O(n²) | ~104 |
O(n³) | ~500 |
O(2^n) | ~25 |
O(n!) | ~11 |
Read this backwards and it becomes a design tool. If the problem says n ≤ 10⁵, an O(n²) solution is 1010 operations and will not pass — you need O(n log n) or better, which immediately suggests sorting, a heap, a hash map, or binary search. If n ≤ 20, an exponential subset enumeration is fine and you should stop looking for something clever.
Best case, average case, worst case
Complexity depends not just on the size of the input but on its content. For a given n, some inputs are easy and some are hard, so we describe three cases:
- Best case — the cheapest input of size
n. Usually the least useful number: it is often an artefact (an already-sorted array, a target found on the first probe) and it never tells you whether the system will hold up. - Worst case — the most expensive input of size
n. This is the default, and rightly so: it is a guarantee, it is what latency SLOs and real-time systems need, and adversarial inputs — a user who can choose the data — turn “unlikely” into “every request”. - Average case — the expected cost over some distribution of inputs. More representative of typical behaviour, but it requires you to state the distribution, and the honest distribution is often unknown.
| Algorithm | Best | Average | Worst | Why worst differs |
|---|---|---|---|---|
| Linear search | O(1) | O(n) | O(n) | target is last or absent |
| Binary search | O(1) | O(log n) | O(log n) | target found at the first midpoint |
| Insertion sort | O(n) | O(n²) | O(n²) | already sorted vs reverse sorted |
| Bubble sort (with early exit) | O(n) | O(n²) | O(n²) | one clean pass vs reverse sorted |
| Selection sort | O(n²) | O(n²) | O(n²) | scans the rest regardless — no best case |
| Merge sort | O(n log n) | O(n log n) | O(n log n) | splits in half regardless of content |
| Quicksort | O(n log n) | O(n log n) | O(n²) | pivot always the min/max → partitions of size 1 and n−1 |
| Heapsort | O(n log n) | O(n log n) | O(n log n) | heap shape is content-independent |
| Hash table lookup | O(1) | O(1) | O(n) | every key collides into one bucket |
| BST search (unbalanced) | O(log n) | O(log n) | O(n) | sorted insertions → degenerate to a list |
| AVL / red-black search | O(log n) | O(log n) | O(log n) | rebalancing bounds the height |
Two of these rows are worth dwelling on, because the gap between average and worst is not just theoretical — it is an attack surface.
Quicksort picks a pivot and partitions around it. With a good pivot the partitions are balanced and the recurrence is T(n) = 2T(n/2) + O(n) = O(n log n). With the worst pivot (always the smallest or largest element) the partitions are size 0 and n−1, giving T(n) = T(n−1) + O(n) = O(n²). A naive implementation that always picks arr[0] hits its worst case on already sorted input, which is depressingly common in the real world. Randomising the pivot or using median-of-three makes the worst case astronomically unlikely rather than routine.
Hash tables degrade to O(n) when all keys land in one bucket. This is not hypothetical: hash flooding is a real denial-of-service technique in which an attacker submits keys chosen to collide, turning every O(1) insertion into an O(n) scan and taking a server down with modest traffic. The fix — randomised per-process hash seeds — is now standard in Python, Ruby, and the JVM (./07-hash-tables.md).
A crucial clarification: best/average/worst is a completely separate axis from O/Ω/θ. The first says which input you are analysing; the second says what kind of bound you are stating. You can combine them freely and it is meaningful to do so:
- “Quicksort’s worst case is
θ(n²)” — a tight bound on the worst input. - “Quicksort’s best case is
Ω(n log n)” — even the luckiest input requires at least that much. - “Insertion sort is
O(n²)” — an upper bound covering all inputs.
Saying “the average case of Big-O” is a category error; the two ideas do not compose that way.
Amortized analysis
Some operations are usually cheap and occasionally very expensive. Reporting only the worst case for a single operation would be misleading if the expensive case cannot happen often. Amortized analysis measures the average cost per operation across a worst-case sequence of operations.
The canonical example is the growing dynamic array. Appending is normally O(1) — write to the next free slot. But when the array is full, it must allocate a bigger block and copy everything over: O(n). So a single append is O(n) worst case. Is append therefore an expensive operation?
No — because the expensive step pays for the cheap ones that follow. If capacity doubles on each resize, then starting from capacity 1 and appending n items, the resizes copy
1 + 2 + 4 + 8 + ... + n/2 + n < 2n elements in total
That geometric series sums to less than 2n, so n appends cost O(n) in total, i.e. O(1) amortized per append. The doubling is essential: growing by a fixed increment of k instead would require n/k resizes copying k, 2k, 3k, …, n elements — a sum of O(n²/k), making each append O(n) amortized. This is exactly why list in Python, vector in C++, and slices in Go all grow multiplicatively.
class DynamicArray:
"""A dynamic array built on a fixed-size backing store, to show the
resize that Python's list hides. Appends are O(1) amortized."""
def __init__(self):
self._capacity = 1
self._size = 0
self._data = [None] * self._capacity
def append(self, x):
if self._size == self._capacity: # rare: happens log2(n) times overall
self._resize(self._capacity * 2)
self._data[self._size] = x # common: O(1)
self._size += 1
def _resize(self, new_capacity):
bigger = [None] * new_capacity # O(n) allocation
for i in range(self._size): # O(n) copy
bigger[i] = self._data[i]
self._data = bigger
self._capacity = new_capacity
def __getitem__(self, i):
if not 0 <= i < self._size:
raise IndexError(i)
return self._data[i] # O(1) always
def __len__(self):
return self._size
a = DynamicArray()
for i in range(1000): # 1000 appends, ~10 resizes,
a.append(i) # < 2000 element copies in total
assert len(a) == 1000 and a[999] == 999
Three standard techniques for proving amortized bounds:
- Aggregate method — bound the total cost of
noperations, then divide byn. The geometric-series argument above is this method. - Accounting (banker’s) method — charge each operation an invented amortized price, save the surplus as credit, and spend the credit on expensive operations. Charge 3 units per append: 1 to write the element, 1 saved to eventually copy it during the next resize, 1 saved to copy an older element that has no credit left. The bank balance never goes negative, so the real total is at most
3n. - Potential method — define a potential function
Φover the structure’s state and define amortized cost as actual cost plusΔΦ. For the dynamic array,Φ = 2·size − capacityworks: it grows during cheap appends, and it is exactly large enough to pay for the copy when a resize happens.
Amortized is not the same as average case. Average case is probabilistic — it depends on assumptions about the input distribution, and an unlucky user can beat it. Amortized is a deterministic worst-case guarantee over a sequence: no adversary can construct n appends that cost more than O(n) in total. That is a much stronger statement, and it is why “amortized O(1)” is safe to rely on in a latency budget while “average O(1)” needs a plan for the tail.
Where you meet amortized bounds in practice:
| Operation | Amortized | Worst case for one op | Mechanism |
|---|---|---|---|
| Dynamic array append | O(1) | O(n) | capacity doubling |
| Hash table insert | O(1) | O(n) | rehash on load-factor breach |
Union-Find find | O(α(n)) | O(log n) | path compression + union by rank |
| Splay tree access | O(log n) | O(n) | rotations restructure the tree |
| Incrementing a binary counter | O(1) per increment | O(log n) | most increments flip one bit |
α(n) is the inverse Ackermann function, which is at most 4 for any n that fits in the observable universe — effectively constant (./16-disjoint-set-union-find.md).
Where the model stops predicting reality
Complexity analysis is a model, and every model has a domain of validity. Four things it deliberately ignores, each of which can dominate real measurements.
1. Constants matter at small n. O notation discards constant factors, but the machine does not. Insertion sort is θ(n²) and merge sort is θ(n log n), yet insertion sort is faster for arrays of roughly 10 to 50 elements, because its constant factor is tiny: no allocation, no recursion, no copying, and a tight inner loop with perfect locality. This is not a curiosity — production sorts exploit it. Timsort (Python’s sorted, Java’s Arrays.sort for objects) runs insertion sort on runs below a threshold and merges the results. Introsort (C++ std::sort) runs quicksort, switches to heapsort when recursion goes too deep to avoid the O(n²) case, and finishes with insertion sort on the small remainder. The theory picks the asymptotic algorithm; the constants pick the cutoff.
2. The memory hierarchy is not flat. The RAM model from ./01-programming-fundamentals-and-pseudocode.md charges O(1) for any memory access. Real hardware charges wildly differently:
| Access | Approximate latency | Relative |
|---|---|---|
| L1 cache | ~1 ns | 1× |
| L2 cache | ~4 ns | 4× |
| L3 cache | ~15 ns | 15× |
| Main memory (RAM) | ~100 ns | 100× |
| NVMe SSD | ~100 µs | 100,000× |
| Spinning disk seek | ~10 ms | 10,000,000× |
A traversal of an array and a traversal of a linked list are both θ(n), but the array streams sequentially through cache lines while the linked list chases pointers to random addresses, missing cache almost every time. The measured gap is routinely 5–10×. This is also the entire reason B-trees exist: when the unit of cost is a disk page rather than a comparison, you want short, wide trees, and log₂ n versus log₂₅₆ n is the difference between 30 reads and 4 (../../postgresql-dba/en/08-indexing-strategies.md).
3. The RAM model’s other fictions. It assumes every arithmetic operation costs one unit regardless of operand size — false for arbitrary-precision integers, where multiplying two d-digit numbers is not O(1). It assumes a single processor, so it says nothing about parallel speedup. It ignores branch prediction, SIMD, allocation cost, and garbage collection pauses. None of this makes the model wrong; it makes it a first-order model whose residual error you handle by measuring.
4. Asymptotically better can be practically useless. Algorithms whose advantage only materialises at input sizes larger than anything that will ever exist are called galactic algorithms. Matrix multiplication has known bounds around O(n^2.37), but the constant factors are so enormous that the O(n³) textbook method — or the O(n^2.807) Strassen method — wins for every matrix anyone actually multiplies. The right question is never “which has the smaller exponent” but “which is faster at my n”.
A related practical point: n is often bounded in reality. If your function sorts the items in a shopping cart, n < 100 forever, and the difference between O(n²) and O(n log n) is nothing. Spending an afternoon replacing the sort is worse than wasted — it adds risk for no benefit. Complexity analysis tells you where the cliff is; it is your job to know whether your input can walk off it.
Best Practices
- State the complexity of every non-trivial function you write, in a comment or docstring. It takes seconds, it forces you to actually check, and it is the single most useful piece of documentation for whoever calls it in a loop later.
- Analyse the worst case by default. It is a guarantee rather than a hope, and it is what an adversary or an unlucky production data distribution will find. Report the average as extra information, never as the headline.
- Say
θwhen you meanθ. “Bubble sort isO(n²)” is true but weak; “bubble sort isθ(n²)in the worst and average case,θ(n)on already-sorted input” is the whole picture in one line. - Compute the actual number before optimizing. Plug your real
ninto the growth table.O(n²)atn = 200is 40,000 operations — 40 microseconds, not a problem. Complexity classes describe scaling; only arithmetic tells you whether this code is slow. - Hunt for accidental quadratics. They are the most common real performance bug and they are invisible in small tests. The usual suspects: a linear
incheck inside a loop,list.pop(0)orinsert(0, …)in a loop, string concatenation with+=in a loop, an ORM query inside a loop (the N+1 problem), orlist.remove()in a loop. - Do not forget the space, including the call stack. A recursive solution
nlevels deep usesO(n)stack — and Python’s default recursion limit of 1000 will stop you long before memory does. Convert deep recursion to iteration with an explicit stack. - Prefer the algorithm whose worst case you can live with. Between quicksort (
O(n log n)average,O(n²)worst) and heapsort (O(n log n)always), pick based on whether a tail-latency spike is acceptable. If it is a user-facing request with a hard SLO, the guaranteed bound is worth the slower constant. - Randomise or rebalance when the worst case is reachable. Random pivots for quicksort, seeded hashes for hash tables, self-balancing trees instead of plain BSTs. These convert “bad on adversarial or sorted input” into “bad with negligible probability”.
- Measure after you analyse. Analysis picks the algorithm; profiling finds the constant factors, cache misses, and allocation churn that analysis abstracted away. Never optimize on a hunch — profile first, and confirm the improvement with a benchmark on realistic input sizes.
- Know the standard library’s complexities cold. Most accidental quadratics come from assuming a built-in is
O(1)when it isO(n). Python’s TimeComplexity table is worth reading once, properly.
References
- roadmap.sh — Data Structures & Algorithms
- Big-O Cheat Sheet
- Big O notation — Wikipedia
- Time complexity — Wikipedia
- Space complexity — Wikipedia
- Amortized analysis — Wikipedia
- Master theorem (analysis of algorithms) — Wikipedia
- Galactic algorithm — Wikipedia
- CLRS — Introduction to Algorithms, Chapters 3 (Growth of Functions), 4 (Divide-and-Conquer), 17 (Amortized Analysis)
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- cp-algorithms — Sieve of Eratosthenes
- Python Wiki — TimeComplexity of built-in types
- Python Documentation —
timeitfor measuring small code snippets