Bài 1 · Cơ bản · 18 phút
Độ phức tạp (Big-O)
Biên soạn bởi Nguyễn Anh Tuấn
Đo chi phí thuật toán bằng Big-O: O(1), O(log n), O(n), O(n log n), O(n²); phân biệt thời gian và bộ nhớ; vì sao bỏ hằng số khi n lớn.
Mèo con cầm một cuốn danh bạ điện thoại đã xếp theo bảng chữ cái và cần tìm số của bạn “Miu”. Có hai cách. Cách thật thà: lật từng trang từ đầu cho tới khi gặp Miu. Cách khôn: mở ngay trang giữa, thấy tên ở đó đứng trước hay sau “Miu”, rồi chỉ tìm tiếp trong nửa còn lại - cứ thế chia đôi.
Với cuốn 1 000 trang, cách thật thà có thể phải lật tới 1 000 lần; cách khôn chỉ cần khoảng 10 lần chia đôi là ra. Phóng to lên 1 triệu trang: 1 000 000 lần so với khoảng 20 lần. Khoảng cách càng giãn ra khi danh bạ càng dày.
- ▸Cùng một việc “tìm một phần tử” có thể làm nhanh hay chậm tuỳ CÁCH làm.
- ▸Sự khác nhau lộ rõ khi dữ liệu lớn dần, không phải khi chỉ có vài phần tử.
- ▸Ta cần một thước đo “nhanh/chậm” không phụ thuộc máy mạnh hay yếu. Thước đó là Big-O.
Big-O mô tả số “thao tác cơ bản” một thuật toán phải làm tăng thế nào khi số phần tử n lớn dần. Nó không đo giây (máy khác nhau cho giây khác nhau) mà đo hình dạng tăng trưởng: n gấp đôi thì công việc tăng mấy lần?
Đếm rất trực giác: một vòng lặp quét hết mảng làm n phép - ta gọi là O(n). Hai vòng lồng nhau, mỗi vòng chạy hết mảng, làm khoảng n × n phép - đó là O(n²):
dem-phep.ts · một vòng so với hai vòng lồng nhau
function demPhep(n: number) {
let mot = 0, hai = 0;
for (let i = 0; i < n; i++) mot++; // 1 vong -> n phep
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++) hai++; // 2 vong long -> n*n phep
return { mot, hai };
}
console.log(demPhep(10)); // n = 10
console.log(demPhep(100)); // n = 100 Kết quả khi chạy
{ mot: 10, hai: 100 }
{ mot: 100, hai: 10000 }n tăng 10 lần (từ 10 lên 100) thì vòng đơn tăng đúng 10 lần (10 → 100), còn vòng lồng tăng tới 100 lần (100 → 10 000). Đó là khác biệt giữa O(n) và O(n²).
- ▸Big-O đếm thao tác cơ bản theo n, không đo thời gian tuyệt đối.
- ▸Một vòng lặp hết mảng = O(n); hai vòng lồng nhau = O(n²).
- ▸Quy ước: bỏ hằng số và số hạng nhỏ - vd 3n + 5 và 100n đều là O(n), vì khi n lớn, lớp tăng trưởng mới quyết định.
Hầu hết thuật toán rơi vào một trong sáu lớp quen mặt dưới đây, xếp từ rẻ tới đắt khi n lớn. Kéo thanh n trong bảng tương tác để xem mỗi lớp “ngốn” bao nhiêu phép tính, và nếu máy chạy 1 tỉ phép mỗi giây thì mất bao lâu:
| Lớp | Số phép | Thời gian |
|---|---|---|
| O(1) | 1 | 1 ns |
| O(log n) | 6 | 6 ns |
| O(n) | 64 | 64 ns |
| O(n log n) | 384 | 384 ns |
| O(n²) | 4.096 | 4 µs |
| O(2ⁿ) | 1.8×10¹⁹ | 584.5 năm |
Cột "Thời gian" giả định máy chạy 1 tỉ phép/giây. Thanh màu vẽ theo thang log (mỗi nấc dài thêm ≈ gấp 10 lần). Kéo n lên để thấy O(n²) và O(2ⁿ) bỏ xa phần còn lại.
Hãy để ý cột bên phải khi kéo n lên. Lớp O(2ⁿ) phình kinh khủng: mới n = 50 đã tốn khoảng 13 ngày, tới n = 100 thì lâu hơn cả tuổi vũ trụ. Trong khi đó O(log n) ở n = 1 triệu chỉ cần vỏn vẹn 20 phép.
- ▸O(1): không phụ thuộc n - vd lấy a[i] hay thêm vào cuối mảng.
- ▸O(log n): chia đôi mỗi bước - vd tìm nhị phân. O(n): quét cả mảng.
- ▸O(n log n): các thuật toán sắp xếp tốt. O(n²): hai vòng lồng. O(2ⁿ): thử mọi khả năng.
Trung thực
Quay lại cuốn danh bạ, lần này bằng code. Tìm tuyến tính quét lần lượt (O(n)); tìm nhị phân chia đôi mỗi bước (O(log n)). Ta cho cả hai cùng tìm số 999 trong mảng đã sắp [0, 1, …, 999] và đếm số phép so sánh:
tim-kiem.ts · đếm phép so sánh của hai cách tìm
function timTuyenTinh(a: number[], x: number) {
let soPhep = 0;
for (let i = 0; i < a.length; i++) {
soPhep++;
if (a[i] === x) return { index: i, soPhep };
}
return { index: -1, soPhep };
}
function timNhiPhan(a: number[], x: number) {
let lo = 0, hi = a.length - 1, soPhep = 0;
while (lo <= hi) {
const giua = (lo + hi) >> 1;
soPhep++;
if (a[giua] === x) return { index: giua, soPhep };
if (a[giua] < x) lo = giua + 1; // x nam o nua ben phai
else hi = giua - 1; // x nam o nua ben trai
}
return { index: -1, soPhep };
}
const day = Array.from({ length: 1000 }, (_, i) => i); // [0, 1, ..., 999] da sap
console.log(timTuyenTinh(day, 999));
console.log(timNhiPhan(day, 999)); Kết quả khi chạy
{ index: 999, soPhep: 1000 }
{ index: 999, soPhep: 10 }Cùng cho ra đáp án index 999, nhưng tuyến tính tốn 1 000 phép so sánh còn nhị phân chỉ 10. Cái giá của tìm nhị phân: mảng phải được sắp xếp trước. Muốn vậy ta cần các thuật toán ở bài Sắp xếp & tìm kiếm; còn nếu muốn một cấu trúc luôn giữ thứ tự trong khi vẫn thêm/xoá được, đó là chuyện của Cây nhị phân tìm kiếm.
- ▸Tìm tuyến tính: O(n), chạy trên mọi mảng. Tìm nhị phân: O(log n), chỉ chạy trên mảng đã sắp.
- ▸Trên 1 000 phần tử: 1 000 phép so với 10 phép - khoảng cách càng giãn khi n lớn.
- ▸Nhị phân đáng công khi bạn tìm nhiều lần; sắp một lần rồi tìm mãi.
Vài điểm tinh tế giúp bạn đọc Big-O cho đúng. Một: Big-O bỏ hằng số, nên 2n và 100n đều là O(n). Nghe vô lý, nhưng khi n đủ lớn, một thuật toán O(n) “xấu” vẫn thắng một thuật toán O(n²) “đẹp”, vì đường thẳng rồi sẽ cắt dưới đường cong.
Hai: khi nói Big-O ta thường nói trường hợp xấu nhất - cái chặn trên chắc chắn. Tìm tuyến tính có thể may mắn gặp ngay phần tử đầu (1 phép), nhưng ta vẫn gọi nó O(n) vì lúc xui nhất phải quét hết. Ba: Big-O cũng dùng cho bộ nhớ (space complexity), không chỉ thời gian - tạo một bản sao của mảng tốn O(n) bộ nhớ.
Trung thực
- ▸Bỏ hằng số: 2n, 100n đều O(n) - lớp tăng trưởng mới quyết định khi n lớn.
- ▸Big-O thường chỉ trường hợp xấu nhất (chặn trên đảm bảo).
- ▸Có cả Big-O cho bộ nhớ, không riêng thời gian.
Giờ mèo con đã có la bàn chi phí: nhìn vào số vòng lặp lồng nhau là đoán được lớp Big-O; thuộc thứ tự sáu lớp từ O(1) tới O(2ⁿ); và biết rằng cùng một bài toán có thể giải nhanh hay chậm tuỳ cách làm. Đây là ngôn ngữ ta sẽ dùng để chấm điểm mọi cấu trúc và thuật toán trong cả khoá.
Bước tiếp theo
Câu hỏi thường gặp
Không. Big-O không đo thời gian thật (máy mạnh hay yếu cho ra giây khác nhau), mà đo HÌNH DẠNG tăng trưởng: khi dữ liệu lớn gấp đôi thì số phép tính tăng bao nhiêu lần. Nhờ vậy một công thức Big-O đúng cho mọi cỗ máy.
Big-O bỏ hằng số nhân vì nó quan tâm tới xu hướng khi n tiến ra rất lớn, lúc đó “gấp đôi” không đổi được hạng tăng trưởng: 2n, 5n hay 100n đều tăng theo đường thẳng, khác hẳn n² tăng theo đường cong. Hằng số vẫn quan trọng trong thực tế, nhưng nó không phải thứ Big-O dùng để xếp hạng.
Không tuyệt đối. Với n nhỏ, một thuật toán O(n²) đơn giản có thể chạy nhanh hơn một thuật toán O(n log n) phức tạp (hằng số nhỏ, ít tốn bộ nhớ). Big-O là la bàn cho dữ liệu LỚN, không phải lời phán cho mọi trường hợp.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Big-O dùng để đo điều gì?
- 1
Big-O bằng lời mèo con
Giải thích cho một người chưa học lập trình: “Big-O là gì và để làm gì?” trong 2 - 3 câu, không dùng công thức.
Hoàn thành khi: Nêu được 2 ý: Big-O cho biết thuật toán chậm đi thế nào khi dữ liệu lớn lên; nhờ đó so sánh được hai cách làm mà không cần bấm giờ.
- 2
Đọc lớp từ vòng lặp
Cho biết Big-O (theo n = a.length) của: (1) một vòng for chạy hết mảng; (2) hai vòng for lồng nhau, mỗi vòng chạy hết mảng; (3) vòng lặp chia đôi n mỗi bước.
Hoàn thành khi: Trả lời đúng: (1)
O(n); (2)O(n²); (3)O(log n). - 3
Xếp hạng sáu lớp
Sắp 6 lớp
O(1),O(log n),O(n),O(n log n),O(n²),O(2ⁿ)từ RẺ nhất tới ĐẮT nhất khi n lớn.Hoàn thành khi: Thứ tự đúng:
O(1)<O(log n)<O(n)<O(n log n)<O(n²)<O(2ⁿ). - 4
Đếm phép so sánh
Mèo con có mảng đã sắp gồm 1 000 000 phần tử. Tìm tuyến tính tốn tối đa bao nhiêu phép so sánh? Tìm nhị phân tốn tối đa bao nhiêu?
Hoàn thành khi: Tuyến tính: tối đa 1 000 000. Nhị phân: khoảng 20 (vì
log₂(1 000 000)≈ 20). - 5
Bỏ số hạng nhỏ
Rút gọn về Big-O: (1)
3n + 5; (2)n² + 100n + 7; (3)2ⁿ + n³.Hoàn thành khi: Đáp án: (1)
O(n); (2)O(n²); (3)O(2ⁿ)- giữ lại số hạng lớn nhất, bỏ hằng số. - 6
Khi nào n² vẫn ổn?
Nêu một tình huống mà thuật toán
O(n²)hoàn toàn chấp nhận được, và một tình huống mà nó “tắc”.Hoàn thành khi: Ví dụ hợp lý:
O(n²)ổn khi n nhỏ (vài chục phần tử, sắp danh sách bạn bè trong lớp); “tắc” khi n lớn (vài triệu phần tử, mỗi thao tác chờ hàng giờ).