- Khi tính Big-O, ta bỏ các hằng số nhân và chỉ giữ lại số hạng tăng nhanh nhất khi
ntiến ra vô cùng (ví dụ3n² + 5n + 10được viết gọn làO(n²)). - Máy chấm bài trực tuyến thực hiện được khoảng 108 phép tính cơ bản trong 1 giây.
- Nhìn giới hạn
ntrong đề bài là biết ngay độ phức tạp cần hướng tới:n ≤ 10³dùng đượcO(n²), cònn = 10⁵bắt buộc phải tìm thuật toánO(n log n)hoặcO(n).
Vì sao không đo tốc độ thuật toán bằng giây mà dùng Big-O?
Khi mới học lập trình và thuật toán, rất tự nhiên ta muốn bấm đồng hồ xem chương trình chạy hết bao nhiêu mili-giây. Tuy nhiên, thời gian chạy tính bằng giây phụ thuộc vào rất nhiều yếu tố bên ngoài thuật toán: máy tính dùng chip mới hay cũ, trình biên dịch có bật cờ tối ưu -O2 hay không, hoặc lúc đó máy có đang mở nhiều ứng dụng khác hay không.
Để đánh giá công bằng hai cách giải trên mọi máy tính, khoa học máy tính đếm số phép toán cơ bản (cộng, trừ, nhân, chia, so sánh, gán biến, truy cập một ô mảng) theo kích thước đầu vào n. Khi n còn nhỏ (như n = 10), cách làm dở và cách làm hay đều chạy xong trong chớp mắt. Sự khác biệt chỉ bộc lộ rõ rệt khi n tăng lên hàng trăm nghìn hoặc hàng triệu phần tử.
Ký hiệu Big-O (đọc là "O lớn") mô tả tốc độ tăng trưởng của số phép toán trong trường hợp xấu nhất (worst-case) khi n rất lớn.
Cùng một bài toán, ba độ phức tạp khác nhau một trời một vực
Giả sử ta cần tính tổng 1 + 2 + 3 + ... + n với n = 1000. Hãy đặt cạnh nhau 3 cách viết dưới đây và đếm chính xác số lần phép cộng được thực hiện:
#include <iostream>
using namespace std;
// Cách 1: Hai vòng lặp lồng nhau — O(n^2) phép cộng
long long tongCapN2(int n, long long& buoc) {
long long s = 0;
buoc = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= i; ++j) {
s += 1;
buoc++;
}
}
return s;
}
// Cách 2: Một vòng lặp — O(n) phép cộng
long long tongCapN(int n, long long& buoc) {
long long s = 0;
buoc = 0;
for (int i = 1; i <= n; ++i) {
s += i;
buoc++;
}
return s;
}
// Cách 3: Công thức cấp số cộng — O(1)
long long tongCapO1(long long n, long long& buoc) {
buoc = 1;
return n * (n + 1) / 2;
}
int main() {
long long b1 = 0, b2 = 0, b3 = 0;
long long k1 = tongCapN2(1000, b1);
long long k2 = tongCapN(1000, b2);
long long k3 = tongCapO1(1000, b3);
cout << k1 << " " << k2 << " " << k3 << "\n"; // 500500 500500 500500
cout << b1 << " " << b2 << " " << b3 << "\n"; // 500500 1000 1
return 0;
}
def tong_cap_n2(n):
s, buoc = 0, 0
for i in range(1, n + 1):
for j in range(1, i + 1):
s += 1
buoc += 1
return s, buoc
def tong_cap_n(n):
s, buoc = 0, 0
for i in range(1, n + 1):
s += i
buoc += 1
return s, buoc
def tong_cap_o1(n):
return n * (n + 1) // 2, 1
k1, b1 = tong_cap_n2(1000)
k2, b2 = tong_cap_n(1000)
k3, b3 = tong_cap_o1(1000)
print(k1, k2, k3) # 500500 500500 500500
print(b1, b2, b3) # 500500 1000 1
Cả ba hàm đều in ra kết quả đúng là 500500, nhưng số bước lặp bên trong hoàn toàn khác nhau:
| Cách viết | Ý tưởng | Số bước khi n = 1.000 | Số bước khi n = 100.000 | Độ phức tạp |
|---|---|---|---|---|
tongCapN2 |
Hai vòng lặp lồng nhau cộng từng đơn vị | 500.500 bước | ~5 × 109 bước (quá 1 giây) | O(n²) |
tongCapN |
Một vòng lặp cộng dồn từ 1 đến n |
1.000 bước | 100.000 bước (chạy tức thì) | O(n) |
tongCapO1 |
Dùng công thức n * (n + 1) / 2 |
1 bước | 1 bước | O(1) |
Với n = 100.000, hàm O(n²) phải thực hiện khoảng 5 tỷ phép cộng và bị hệ thống chấm bài báo lỗi quá thời gian (TLE), trong khi hàm O(n) và O(1) trả kết quả ngay lập tức.
Ba quy tắc rút gọn biểu thức Big-O trong 10 giây
Giả sử bạn đếm được một thuật toán thực hiện chính xác T(n) = 4n² + 15n + 200 phép tính. Để suy ra ký hiệu Big-O, bạn chỉ cần áp dụng 3 quy tắc:
- Quy tắc 1 — Bỏ mọi hằng số nhân:
O(7n)viết thànhO(n);O(n² / 2)như trong hàmtongCapN2ở trên (thực hiệnn(n + 1) / 2 = 0.5n² + 0.5nbước) cũng viết thànhO(n²). - Quy tắc 2 — Chỉ giữ lại số hạng có bậc cao nhất: Khi
n = 100.000,n² = 10¹⁰lớn gấp trăm nghìn lần15n, nên15nvà200không còn đáng kể. Do đó4n² + 15n + 200rút gọn thành O(n²). - Quy tắc 3 — Cộng cho các đoạn nối tiếp, nhân cho các vòng lặp lồng nhau:
- Hai vòng lặp đứng nối tiếp nhau: vòng đầu chạy
O(n), vòng sau chạyO(n log n)→ tổng cộng làO(n + n log n) = O(n log n). - Hai vòng lặp lồng nhau: vòng ngoài chạy
nlần, mỗi lần vòng trong chạylog nbước → tổng cộng làO(n × log n) = O(n log n).
- Hai vòng lặp đứng nối tiếp nhau: vòng đầu chạy
Chủ đề này nằm trong Lecture 01 - Algorithmic Complexity (Độ phức tạp thuật toán) của Khóa Blue 2.0. Xem đề cương
Bảy bậc độ phức tạp thường gặp nhất từ nhanh đến chậm
Hình dưới đây xếp các độ phức tạp phổ biến theo tốc độ tăng của số phép tính khi kích thước dữ liệu n lớn dần:
Để nhận diện nhanh trong code, bạn hãy đối chiếu cách biến chạy thay đổi ở từng vòng lặp:
- O(1) — Độ phức tạp hằng số: Số bước cố định không phụ thuộc
n. Ví dụ: truy cập phần tửa[i], kiểm tra chẵn lẻn % 2 == 0, hoặc tính tổng đoạn bằng mảng cộng dồn sau khi đã tiền xử lý. - O(log n) — Độ phức tạp logarit: Sau mỗi bước, phạm vi bài toán giảm đi một nửa (hoặc biến chạy nhân đôi
x *= 2, chia đôin /= 2). Vớin = 1.024, vòng lặp nhân đôi chỉ chạy đúng10lần vì 210 = 1024. Tiêu biểu nhất là tìm kiếm nhị phân và thuật toán Euclid tìm ước chung lớn nhất. - O(√n) — Độ phức tạp căn bậc hai: Vòng lặp chạy với điều kiện
i * i <= n. Vớin = 1.024, vòng lặp chỉ chạy32lần. Thường gặp khi kiểm tra một số nguyên tố hoặc liệt kê các ước số củan. - O(n) — Độ phức tạp tuyến tính: Duyệt qua từng phần tử của mảng một số lần cố định. Tiêu biểu là tìm kiếm tuần tự, kỹ thuật hai con trỏ và cửa sổ trượt.
- O(n log n) — Tuyến tính nhân logarit: Xuất hiện khi một vòng lặp
nlần bao ngoài một thao tácO(log n), hoặc khi chia đôi mảng rồi gộp lại. Đây là độ phức tạp của các thuật toán sắp xếp nhanh như Merge Sort,std::sort(C++) vàsorted()(Python). - O(n²) — Độ phức tạp bậc hai: Hai vòng lặp
forlồng nhau cùng chạy từ1đếnn. Thường gặp ở các thuật toán sắp xếp cơ bản (Bubble Sort, Insertion Sort, Selection Sort) hoặc khi duyệt mọi cặp phần tử(i, j). - O(2n) và O(n!) — Độ phức tạp hàm mũ / giai thừa: Số bước tăng bùng nổ khi sinh mọi tập con (2n) hoặc mọi hoán vị (
n!) bằng thuật toán quay lui. Chỉ chạy được khinrất nhỏ (n ≤ 20với 2n vàn ≤ 10vớin!).
Đoạn code dưới đây minh họa trực tiếp số bước chạy của các mẫu vòng lặp O(log n), O(√n) và O(n log n) khi n = 1024:
#include <iostream>
using namespace std;
int main() {
int n = 1024;
// 1. Vòng lặp nhân đôi biến chạy -> O(log n)
int buocLog = 0;
for (int x = 1; x < n; x *= 2) {
buocLog++;
}
// 2. Vòng lặp kiểm tra đến căn bậc hai -> O(sqrt(n))
int buocCan = 0;
for (int i = 1; i * i <= n; ++i) {
buocCan++;
}
// 3. Vòng ngoài n lần, vòng trong nhân đôi -> O(n log n)
int buocNLogN = 0;
for (int i = 1; i <= n; ++i) {
for (int x = 1; x < n; x *= 2) {
buocNLogN++;
}
}
cout << buocLog << " " << buocCan << " " << buocNLogN << "\n"; // 10 32 10240
return 0;
}
n = 1024
# 1. Vòng lặp nhân đôi biến chạy -> O(log n)
buoc_log, x = 0, 1
while x < n:
buoc_log += 1
x *= 2
# 2. Vòng lặp kiểm tra đến căn bậc hai -> O(sqrt(n))
buoc_can, i = 0, 1
while i * i <= n:
buoc_can += 1
i += 1
# 3. Vòng ngoài n lần, vòng trong nhân đôi -> O(n log n)
buoc_nlogn = 0
for _ in range(n):
x = 1
while x < n:
buoc_nlogn += 1
x *= 2
print(buoc_log, buoc_can, buoc_nlogn) # 10 32 10240
Bảng tra nhanh: Nhìn giới hạn N trong đề bài để chọn thuật toán
Trong hầu hết các kỳ thi lập trình và hệ thống chấm bài trực tuyến, giới hạn thời gian cho mỗi test thường là 1 giây (tương đương khoảng 107 đến 108 phép tính cơ bản). Vì vậy, trước khi bắt tay vào viết code, bạn hãy nhìn dòng "Giới hạn" (Constraints) của đề bài và tra bảng sau:
| Giới hạn của n trong đề | Độ phức tạp tối đa chạy kịp 1 giây | Các hướng thuật toán nên nghĩ tới ngay |
|---|---|---|
n ≤ 10 |
O(n!) | Duyệt mọi hoán vị, quay lui toàn bộ trạng thái |
n ≤ 20 |
O(2n) hoặc O(n · 2n) | Quay lui sinh tập con, duyệt bitmask |
n ≤ 500 |
O(n³) | Ba vòng lặp lồng nhau, nhân ma trận, Floyd-Warshall |
n ≤ 5.000 |
O(n²) | Hai vòng lặp lồng nhau, sắp xếp cơ bản, quy hoạch động hai chiều |
n ≤ 200.000 (2 × 105) |
O(n log n) hoặc O(n) | Sắp xếp + tìm kiếm nhị phân, hai con trỏ, cửa sổ trượt, tham lam |
n ≤ 10.000.000 (107) |
O(n) | Duyệt một lần, mảng cộng dồn, sàng nguyên tố |
n ≤ 10¹² |
O(√n) | Duyệt ước đến căn bậc hai, phân tích thừa số nguyên tố |
n ≤ 10¹⁸ |
O(log n) hoặc O(1) | Tìm kiếm nhị phân, lũy thừa nhị phân, công thức toán học |
Độ phức tạp bộ nhớ (Space Complexity) là gì?
Bên cạnh thời gian chạy (Time Complexity), mỗi bài toán còn có giới hạn bộ nhớ (thường là 256 MB). Độ phức tạp bộ nhớ đo lượng ô nhớ phụ thêm mà thuật toán cần cấp phát theo n:
- Nếu chỉ dùng vài biến đơn lẻ (
lo,hi,mid,tong): bộ nhớ phụ là O(1). - Nếu tạo thêm một mảng phụ
prefgồmnphần tử kiểulong long(mỗi phần tử 8 byte): bộ nhớ là O(n). Vớin = 10^6, mảng này tốn khoảng8 MB— rất an toàn. - Nếu tạo một ma trận hai chiều
a[10000][10000]kiểuint(4 byte): tổng số phần tử là 108, ngốn khoảng400 MBvà sẽ bị báo lỗi Memory Limit Exceeded (MLE — vượt quá bộ nhớ). - Với hàm đệ quy gọi sâu
ntầng, mỗi tầng đang chờ trên ngăn xếp (call stack) cũng tiêu tốn một khung bộ nhớ, nên độ phức tạp bộ nhớ của nó là O(n) dù bạn không khai báo mảng nào.
Lỗi hay gặp khi tính độ phức tạp
- Tưởng cứ có 2 vòng lặp lồng nhau là O(n²). Ở kỹ thuật hai con trỏ hoặc sàng nguyên tố Eratosthenes, dù có vòng lặp bên trong nhưng biến chạy không quay lại từ đầu hoặc nhảy bước
j += i, nên tổng số bước chỉ làO(n)hoặcO(n log log n). Hãy đếm tổng số lần câu lệnh trong cùng được chạy, đừng chỉ đếm số chữforhaywhile. - Quên chi phí ẩn của hàm thư viện bên trong vòng lặp. Các thao tác như nối xâu, xóa phần tử đầu của
vector/list, hoặc tìm kiếmx in listtrong Python đều mấtO(n). Nếu đặt chúng bên trong một vòng lặpnlần, tổng độ phức tạp sẽ bị đẩy lênO(n²). - Truyền mảng/vector theo giá trị trong C++. Viết
int tinh(vector<int> a)(thiếu dấu tham chiếu&) khiến máy tính phải chép lại toàn bộnphần tử mỗi lần gọi hàm, biến một hàmO(1)hoặcO(log n)thànhO(n). Luôn viếtconst vector<int>& a.
Luyện ngay trên Big-O Coding
Hãy áp dụng ngay cách nhìn giới hạn n để chọn thuật toán phù hợp cho các bài tập miễn phí dưới đây:
Câu hỏi thường gặp
O(1) có phải lúc nào cũng chỉ chạy đúng 1 phép tính không?
Không. O(1) nghĩa là số phép tính là một hằng số cố định không phụ thuộc vào n. Một hàm thực hiện cố định 50 phép cộng dù n = 10 hay n = 10^9 thì vẫn được gọi là có độ phức tạp O(1).
Cơ số của log trong O(log n) là cơ số mấy?
Trong khoa học máy tính, log n mặc định hiểu là logarit cơ số 2 (log2 n) do thuật toán chia đôi dữ liệu. Về mặt ký hiệu Big-O, mọi cơ số logarit chỉ chênh nhau một hằng số nhân (vì loga n = log2 n / log2 a), mà hằng số nhân thì được lược bỏ trong Big-O.
Vì sao cùng là O(n) nhưng có bài chạy nhanh, có bài chạy chậm hơn?
Vì Big-O đã lược bỏ hệ số hằng số đứng trước n và độ nặng nhẹ của từng phép tính. Một vòng lặp O(n) chỉ cộng số nguyên sẽ nhanh hơn nhiều so với một vòng lặp O(n) có phép chia lấy dư, cấp phát bộ nhớ động hoặc tra cứu bảng băm ở mỗi bước.
Khi nào thuật toán O(n²) lại chạy nhanh hơn O(n log n)?
Khi kích thước mảng n rất nhỏ (thường n ≤ 20). Lúc này hằng số phụ của thuật toán đơn giản như Insertion Sort rất nhỏ, không tốn chi phí gọi hàm đệ quy hay cấp phát mảng tạm như Merge Sort.
Luyện phỏng vấn Google, Meta, Amazon theo Interview Patterns: 16 buổi online live, lớp 7–15 người, C++/Python/Java. Học thử buổi 1 miễn phí.


