1.1 Tại sao cần Big-O?
Hãy tưởng tượng bạn viết hai hàm findUser đều "đúng" — đều trả về user theo email.
Nhưng cách thứ nhất chạy 0.001 giây, cách thứ hai chạy 4 giây khi database có 1 triệu user. Cả hai đều đúng logic.
Khác biệt nằm ở độ phức tạp thuật toán.
Big-O là cách diễn đạt tốc độ tăng của thuật toán theo kích thước input — không quan tâm máy tính nhanh hay chậm, không quan tâm ngôn ngữ là JS hay C, chỉ quan tâm: khi n tăng gấp đôi, thời gian chạy tăng bao nhiêu lần?
- O(1): bao nhiêu input cũng vẫn 1 bước. Vd: lấy phần tử thứ i của array.
- O(n): input gấp đôi → thời gian gấp đôi. Vd: tìm tuyến tính.
- O(n²): input gấp đôi → thời gian gấp 4. Vd: hai vòng for lồng nhau.
Câu chuyện n = 1 triệu
Giả sử máy bạn chạy 10⁸ phép tính/giây. Đây là thời gian thực tế để xử lý n = 1,000,000:
| Big-O | Số phép tính | Thời gian chạy | Chấp nhận được? |
|---|---|---|---|
O(1) | 1 | tức thì | ✓ Tuyệt vời |
O(log n) | ~20 | tức thì | ✓ Tuyệt vời |
O(n) | 10⁶ | 0.01s | ✓ Tốt |
O(n log n) | ~2×10⁷ | 0.2s | ✓ Tốt |
O(n²) | 10¹² | ~3 giờ | ✗ Quá chậm |
O(n³) | 10¹⁸ | ~300 năm | ✗ Vô dụng |
O(2ⁿ) | vũ trụ | không bao giờ xong | ✗ Thảm hoạ |
Nhìn bảng này, bạn sẽ hiểu vì sao phỏng vấn luôn hỏi "Big-O của giải pháp này là gì?".
Câu trả lời O(n²) với input lớn = bài làm không pass.
1.2 Định nghĩa Big-O
Định nghĩa toán học (đơn giản hoá cho lập trình viên):
f(n) = O(g(n)) nghĩa là: tồn tại hai hằng số c > 0 và n₀ > 0
sao cho với mọi n ≥ n₀, ta có f(n) ≤ c · g(n).
Nói nôm na:
- Big-O là cận trên của tốc độ tăng (worst case).
- Khi n đủ lớn,
f(n)không tăng nhanh hơng(n)theo bậc. - Hằng số
cđể "bỏ qua" phần multiply nhỏ — vì lúc n lớn, hằng số không quan trọng.
Ví dụ trực quan
function sumArray(arr) {
let total = 0; // 1 bước
for (let i = 0; i < arr.length; i++) {
total += arr[i]; // n bước
}
return total; // 1 bước
}
Tổng số phép tính: 1 + n + 1 = n + 2.
Nhưng Big-O bỏ qua hằng số và chỉ giữ bậc cao nhất → O(n).
n và n + 2 là không đáng kể.
Big-O quan tâm tốc độ tăng dài hạn, không quan tâm chính xác từng phép tính.
1.3 Quy tắc đếm Big-O
Quy tắc 1 — Bỏ hằng số
// Big-O = O(n), không phải O(2n)
function double(arr) {
for (let i = 0; i < arr.length; i++) console.log(arr[i]); // n
for (let i = 0; i < arr.length; i++) console.log(arr[i]); // n
// 2n → O(n)
}
Quy tắc 2 — Giữ bậc cao nhất
// Big-O = O(n²), không phải O(n² + n)
function example(arr) {
for (let i = 0; i < arr.length; i++) { // n
for (let j = 0; j < arr.length; j++) { // n
console.log(arr[i], arr[j]); // 1 → tổng n²
}
}
for (let i = 0; i < arr.length; i++) { // n
console.log(arr[i]); // 1 → tổng n
}
// n² + n → O(n²)
}
Quy tắc 3 — Vòng lặp lồng nhau thì NHÂN
// Big-O = O(n²)
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
// ...
}
}
// Big-O = O(n × m) — KHÁC NHAU khi n và m khác nhau!
for (let i = 0; i < n; i++) {
for (let j = 0; j < m; j++) {
// ...
}
}
O(n × m), KHÔNG phải O(n²).
Nếu bạn nói "n²" mà n và m không liên quan → trừ điểm.
Quy tắc 4 — Vòng lặp tuần tự thì CỘNG
// Big-O = O(n + m), gộp lại = O(max(n, m))
for (let i = 0; i < n; i++) { /* ... */ }
for (let j = 0; j < m; j++) { /* ... */ }
Quy tắc 5 — Chia đôi thì có log
// Big-O = O(log n) — mỗi vòng n giảm một nửa
let i = n;
while (i > 1) {
i = Math.floor(i / 2);
// ...
}
// Binary search là ví dụ kinh điển O(log n)
function binarySearch(arr, target) {
let lo = 0, hi = arr.length - 1;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
if (arr[mid] === target) return mid;
if (arr[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
log₂(n).
Trong Big-O, log₂ hay log₁₀ hay ln đều viết chung là log n
(chúng chỉ khác nhau bởi hằng số nhân — và Big-O bỏ hằng số).
1.4 Các "họ" Big-O — từ tốt nhất đến tệ nhất
Đây là 9 họ Big-O bạn gặp 99% trong phỏng vấn. Học thuộc thứ tự này.
O(1) — Constant
Số phép tính không phụ thuộc kích thước input.
function getFirst(arr) { return arr[0]; } // O(1)
function isEven(n) { return n % 2 === 0; } // O(1)
O(log n) — Logarithmic
Mỗi bước, không gian tìm kiếm giảm một nửa. Cực nhanh ngay cả với n khổng lồ.
// Binary search — đã ví dụ ở trên: O(log n)
// Tree traversal trên balanced BST tìm kiếm: O(log n)
O(n) — Linear
Đi qua input đúng 1 lần. Đây là baseline mà mọi giải pháp tốt nên đạt được.
function findMax(arr) {
let max = arr[0];
for (let i = 1; i < arr.length; i++) {
if (arr[i] > max) max = arr[i];
}
return max;
} // O(n)
O(n log n) — Log-linear
Hầu hết thuật toán sort hiệu quả (Merge Sort, Heap Sort, TimSort) đạt mức này. Đây là giới hạn dưới của comparison-based sort.
O(n²) — Quadratic
Hai vòng for lồng nhau. Phải tránh nếu input có thể lớn.
// Bubble sort, selection sort, brute-force two sum
function hasPairSum(arr, target) {
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] + arr[j] === target) return true;
}
}
return false;
} // O(n²)
// Cải tiến với Hash Set → O(n)
function hasPairSumFast(arr, target) {
const seen = new Set();
for (const x of arr) {
if (seen.has(target - x)) return true;
seen.add(x);
}
return false;
} // O(n)
O(2ⁿ) — Exponential
Mỗi input mới nhân đôi số trường hợp. Thường xuất hiện trong đệ quy không có memoization.
// Fibonacci đệ quy "ngây thơ" — O(2ⁿ), kinh điển slow
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// fib(40) đã chậm thấy rõ. fib(60) chạy hàng giờ.
// Cải tiến với memoization → O(n)
function fibMemo(n, memo = {}) {
if (n <= 1) return n;
if (memo[n]) return memo[n];
return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}
O(n!) — Factorial
Liệt kê mọi hoán vị. Chỉ chạy được khi n ≤ ~10. Bài kinh điển: Travelling Salesman brute force.
1.5 Big-Θ (Theta) và Big-Ω (Omega) — khi nào cần phân biệt?
Trong giao tiếp hàng ngày, lập trình viên dùng "Big-O" cho mọi thứ. Nhưng học thuật phân biệt 3 ký hiệu:
Big-Ω (Omega)
Cận dưới — best case.
"Thuật toán này ít nhất nhanh bằng g(n)."
Big-Θ (Theta)
Cận chặt — vừa trên vừa dưới.
"Thuật toán này chính xác bậc g(n)."
Big-O
Cận trên — worst case.
"Thuật toán này không chậm hơn g(n)."
Ví dụ: thuật toán Linear Search:
Ω(1)— best case: phần tử ở vị trí đầu tiên.O(n)— worst case: phần tử ở cuối hoặc không có.- Không có Θ chặt vì best/worst khác nhau.
Ví dụ: thuật toán tính tổng mảng:
Ω(n),O(n),Θ(n)— luôn duyệt hết mảng dù sao đi nữa.
1.6 Space Complexity — không chỉ thời gian, còn bộ nhớ
Big-O không chỉ đo thời gian, còn đo bộ nhớ mà thuật toán chiếm. Trong phỏng vấn, hỏi "Time complexity?" thường đi kèm "Space complexity?".
Cách tính space
Space = bộ nhớ thêm mà thuật toán cần (không tính input).
// Time: O(n), Space: O(1)
// Chỉ dùng vài biến cố định, không phụ thuộc n
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) total += arr[i];
return total;
}
// Time: O(n), Space: O(n)
// Tạo mảng mới có kích thước n
function double(arr) {
const result = [];
for (let i = 0; i < arr.length; i++) result.push(arr[i] * 2);
return result;
}
Stack space từ đệ quy
Mỗi lời gọi đệ quy chiếm 1 frame trên call stack. Đệ quy sâu n lần → space O(n)
ngay cả khi không tạo array nào.
// Time: O(n), Space: O(n) — vì call stack sâu n
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// Time: O(n), Space: O(1) — phiên bản iterative không tốn stack
function factorialIter(n) {
let result = 1;
for (let i = 2; i <= n; i++) result *= i;
return result;
}
RangeError: Maximum call stack size exceeded. Khi gặp input lớn, chuyển sang iterative.
1.7 Amortized Analysis — "trung bình" thông minh
Đôi khi 1 thao tác đôi lúc chậm, đôi lúc cực nhanh, nhưng trung bình thì nhanh. Đây là amortized analysis (phân tích biên hoá).
Ví dụ kinh điển: Dynamic Array (JS Array)
Khi bạn arr.push(x), hầu hết thời gian là O(1). Nhưng khi mảng đầy, JS engine phải:
(1) cấp phát mảng mới gấp đôi kích thước, (2) copy hết phần tử cũ sang. Lúc đó push tốn O(n).
Vậy push() có phải O(n) không?
Giả sử mảng có capacity 8, bạn push 8 lần đầu — mỗi lần O(1). Lần thứ 9 → resize lên 16, copy 8 phần tử cũ → O(8). Lần 10-16: O(1) mỗi lần. Lần 17 → resize lên 32, copy 16 → O(16)...
Tổng cost của n lần push: n + (1 + 2 + 4 + 8 + ... + n/2) ≈ 3n
Trung bình mỗi push: 3n / n = O(1) amortized.
Vì thế, trong tài liệu của V8 và mọi sách DSA, push được ghi là O(1) amortized.
Ví dụ khác: Hash Table rehashing
Khi load factor vượt ngưỡng (vd 0.75), hash table phải resize và rehash mọi key — O(n). Nhưng amortized cost của insert vẫn là O(1).
1.8 Big-O của các thao tác JavaScript phổ biến
Bảng này bạn nên thuộc lòng. Phỏng vấn rất hay hỏi "Time complexity của arr.includes() là gì?".
Array
| Thao tác | Big-O | Ghi chú |
|---|---|---|
arr[i] (access) | O(1) | Index trực tiếp |
arr.length | O(1) | Cached property |
arr.push(x) | O(1) amortized | Resize đôi khi xảy ra |
arr.pop() | O(1) | Bỏ phần tử cuối |
arr.shift() | O(n) | Phải dịch hết phần tử về trước! |
arr.unshift(x) | O(n) | Phải dịch hết phần tử ra sau |
arr.splice(i, k) | O(n) | Cắt + dịch |
arr.slice(i, j) | O(j-i) | Tạo mảng mới |
arr.includes(x) | O(n) | Tuyến tính |
arr.indexOf(x) | O(n) | Tuyến tính |
arr.find(fn) | O(n) | Tuyến tính |
arr.sort() | O(n log n) | TimSort trong V8 |
arr.reverse() | O(n) | In-place swap |
arr.concat(arr2) | O(n+m) | Tạo mảng mới |
arr.map / filter / forEach | O(n) | Duyệt 1 lần |
Map / Set
| Thao tác | Big-O |
|---|---|
map.get(k) / map.set(k, v) / map.has(k) / map.delete(k) | O(1) trung bình |
set.add(x) / set.has(x) / set.delete(x) | O(1) trung bình |
map.size / set.size | O(1) |
Duyệt for...of Map/Set | O(n) |
String
| Thao tác | Big-O |
|---|---|
str[i] | O(1) |
str.length | O(1) |
str.includes(s) | O(n·m) |
str.indexOf(s) | O(n·m) |
str + str2 (concat) | O(n+m) |
str.split / slice | O(n) |
arr.shift() và arr.unshift() là O(n) — đừng dùng để cài queue!
Nếu cần queue, dùng linked list hoặc dùng index thủ công, hoặc thư viện denque.
Code while (queue.length) { queue.shift(); ... } trông sạch nhưng chạy O(n²) cho n phần tử!
Demo trực quan — Linear Search vs Binary Search
Tìm số 17 trong mảng đã sort:
Linear search — duyệt từng ô (worst case 8 bước)
Binary search — chia đôi (chỉ 3 bước!)
Bước 1: kiểm tra giữa = 14 → nhỏ hơn 17 → loại nửa trái.
Bước 2: kiểm tra giữa nửa phải = 16 → nhỏ hơn 17 → loại tiếp.
Bước 3: kiểm tra phần tử cuối = 17 → tìm thấy! Tổng 3 bước (log₂ 8 = 3).
Bài tập
Hãy tự tính Big-O trước khi xem đáp án (đáp án ở phần Quiz bên dưới có lời giải).
function f(n) {
for (let i = 0; i < n; i++) console.log(i);
for (let j = 0; j < n * n; j++) console.log(j);
}
Big-O time? Space?
function f(n) {
let i = 1;
while (i < n) {
console.log(i);
i *= 2;
}
}
Big-O time?
function f(arr1, arr2) {
for (const a of arr1) {
for (const b of arr2) {
if (a === b) return true;
}
}
return false;
}
Big-O time? Có cải tiến được không?
function f(n) {
if (n <= 0) return;
console.log(n);
f(n - 1);
f(n - 1);
}
Big-O time? (gợi ý: vẽ recursion tree)
function f(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = i; j < arr.length; j *= 2) {
console.log(arr[i], arr[j]);
}
}
}
Big-O time? (khó! gợi ý: vòng trong là O(log n))
🧪 Quiz cuối chương — kiểm tra hiểu biết
Click vào đáp án bạn chọn. Click để xem giải thích.
Câu 1. Big-O của arr.unshift(x) trong JS là?
Đáp án: O(n). unshift thêm phần tử vào đầu mảng,
phải dịch hết phần tử cũ sang phải 1 vị trí → tốn n thao tác. Đây là cái bẫy phỏng vấn nổi tiếng.
Câu 2. Đoạn code này có Big-O time là?
for (let i = 0; i < n; i++) {
for (let j = 0; j < m; j++) {
console.log(i, j);
}
}
Đáp án: O(n × m). Hai vòng lồng nhau chạy trên hai biến độc lập n và m. Đáp án "n²" sai vì n và m không nhất thiết bằng nhau.
Câu 3. Big-O của hàm này?
function f(n) {
let i = 1;
while (i < n) {
console.log(i);
i *= 3;
}
}
Đáp án: O(log n). Mỗi vòng lặp nhân i với 3 — tức là cần log₃(n) lần để i vượt n.
Trong Big-O, log₃ và log₂ đều là log n vì khác nhau hằng số.
Câu 4. Hàm này có Time và Space complexity là?
function fact(n) {
if (n <= 1) return 1;
return n * fact(n - 1);
}
Đáp án: Time O(n), Space O(n). Đệ quy n lần → time O(n). Mỗi lời gọi tốn 1 frame trên call stack → space O(n). Nếu viết iterative thì space mới là O(1).
Câu 5. Tại sao arr.push(x) được coi là O(1) dù đôi khi phải resize?
Đáp án: Amortized analysis. Tuy có những lần push tốn O(n) khi resize, nhưng cost được "biên hoá" đều ra n lần push → trung bình vẫn O(1) per operation.
Câu 6. Big-O time của hàm này (Bài 4 ở trên)?
function f(n) {
if (n <= 0) return;
console.log(n);
f(n - 1);
f(n - 1);
}
Đáp án: O(2ⁿ). Mỗi lần gọi sinh ra 2 lời gọi con (với n-1). Recursion tree có 2 + 4 + 8 + ... + 2ⁿ ≈ 2ⁿ⁺¹ node → O(2ⁿ).
Câu 7. Sau bao nhiêu bước thì binary search tìm được phần tử trong mảng 1 triệu phần tử (worst case)?
Đáp án: ~20 bước. log₂(1,000,000) ≈ 19.93 → 20 bước. Đó chính là sức mạnh của O(log n).
Câu 8. Đoạn nào có Big-O chậm hơn?
Đáp án: O(0.001 · n²). Big-O bỏ hằng số nên O(n + 1000) = O(n) và O(0.001·n²) = O(n²). Khi n đủ lớn, n² >> n bất kể hằng số nhỏ thế nào. Với n = 10⁶: thứ nhất ~10⁶ phép, thứ hai ~10⁹ phép.
Tổng kết chương 1
- ✅ Big-O đo tốc độ tăng, không phải thời gian tuyệt đối
- ✅ Quy tắc cơ bản: bỏ hằng số, giữ bậc cao nhất
- ✅ Loop lồng nhau → nhân; loop tuần tự → cộng; chia đôi → log
- ✅ Phân biệt time complexity và space complexity (call stack tốn space!)
- ✅ Amortized analysis giải thích vì sao
push= O(1) dù đôi khi resize - ✅ Thuộc lòng Big-O của JS Array/Map/Set built-in — phỏng vấn rất hay hỏi
- Tự code lại các đoạn ở mục 1.4 — không nhìn bài, gõ tay.
- Mở 5 đoạn code random từ project cũ của bạn → tự tính Big-O của nó.
- Quay lại làm 8 câu quiz đến khi đúng 100%.