Độ phức tạp
Sau bài này bạn sẽ làm được
- Xác định Big-O của một đoạn mã bằng cách nhìn vòng lặp
- Áp dụng hai quy tắc rút gọn: bỏ hằng số và giữ bậc cao nhất
- Quy đổi độ phức tạp ra thời gian chạy thực tế
- Phân biệt độ phức tạp thời gian với độ phức tạp không gian
Đây là bài dài nhất Chương 7 và là công cụ bạn dùng suốt phần đời còn lại của việc lập trình. Big-O trả lời một câu hỏi duy nhất: khi dữ liệu lớn gấp mười lần thì chương trình chậm đi bao nhiêu lần.
#Ý tưởng
Vì sao lại bỏ hằng số? Vì hằng số phụ thuộc máy, phụ thuộc trình biên dịch, phụ thuộc cả nhiệt độ phòng. Còn tốc độ tăng thì không đổi dù bạn chạy trên máy nào.
#Bảng các mức phổ biến
| Ký hiệu | Tên | n=10 | n=1 000 | n=10⁶ |
|---|---|---|---|---|
| O(1) | Hằng số | 1 | 1 | 1 |
| O(log n) | Logarit | 3 | 10 | 20 |
| O(n) | Tuyến tính | 10 | 10³ | 10⁶ |
| O(n log n) | Tuyến tính nhân log | 33 | 10⁴ | 2 × 10⁷ |
| O(n²) | Bình phương | 100 | 10⁶ | 10¹² |
| O(n³) | Lập phương | 1 000 | 10⁹ | 10¹⁸ |
| O(2ⁿ) | Hàm mũ | 1 024 | quá lớn | quá lớn |
| O(n!) | Giai thừa | 3,6 × 10⁶ | quá lớn | quá lớn |
| Mức | Ví dụ thuật toán trong khóa học |
|---|---|
| O(1) | Truy cập a[i], thêm và lấy phần tử của ngăn xếp |
| O(log n) | Tìm kiếm nhị phân, lũy thừa nhanh ở Bài 7.2 |
| O(n) | Duyệt mảng, tìm kiếm tuần tự, tính tổng |
| O(n log n) | Sắp xếp trộn, sắp xếp nhanh, sắp xếp vun đống |
| O(n²) | Sắp xếp nổi bọt, hai vòng lặp lồng nhau |
| O(n³) | Nhân ma trận theo cách thông thường |
| O(2ⁿ) | Fibonacci đệ quy không nhớ kết quả, duyệt mọi tập con |
| O(n!) | Sinh mọi hoán vị, giải bài toán người bán hàng bằng vét cạn |
#Cách xác định nhanh
Với phần lớn mã trong khóa học, chỉ cần nhìn cấu trúc vòng lặp là ra ngay. Đây là bộ mẫu để nhận diện.
/* O(1): số bước không phụ thuộc n */
int x = a[5];
x = x * 2 + 1;
/* O(n): một vòng lặp chạy n lượt */
for (int i = 0; i < n; ++i) {
tong += a[i];
}
/* O(n²): hai vòng lồng nhau, mỗi vòng n lượt */
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
c[i][j] = 0;
}
}
/* O(n²): vẫn bậc hai dù vòng trong ngắn dần
Tổng: n + (n-1) + ... + 1 = n(n+1)/2 */
for (int i = 0; i < n; ++i) {
for (int j = i; j < n; ++j) {
lam_gi_do();
}
}
/* O(log n): biến chạy NHÂN hoặc CHIA mỗi lượt */
for (int i = 1; i < n; i *= 2) {
lam_gi_do();
}
while (n > 0) {
n /= 2;
}
/* O(n log n): vòng ngoài n lượt, vòng trong log n lượt */
for (int i = 0; i < n; ++i) {
for (int j = 1; j < n; j *= 2) {
lam_gi_do();
}
}
/* O(n + m): hai vòng NỐI TIẾP, không lồng nhau */
for (int i = 0; i < n; ++i) { lam_a(); }
for (int i = 0; i < m; ++i) { lam_b(); }#Hai quy tắc rút gọn
Quy tắc 1: bỏ hằng số
O(2n) -> O(n)
O(500) -> O(1)
O(n / 2) -> O(n)
O(3n²) -> O(n²)
O(log₂ n) -> O(log n) cơ số không quan trọngQuy tắc 2: giữ bậc cao nhất
O(n² + n) -> O(n²)
O(n² + n³) -> O(n³)
O(n + log n) -> O(n)
O(n log n + n) -> O(n log n)Lý do: khi n lớn, số hạng bậc cao át hẳn các số hạng còn lại. Với n bằng một triệu, n² là 10¹² còn n chỉ là 10⁶, tức là số hạng nhỏ chiếm chưa tới một phần triệu tổng.
#Quy đổi ra thời gian thật
Đây là phần dùng được ngay: nhìn ràng buộc của đề bài là biết thuật toán nào chấp nhận được.
Máy tính hiện đại làm khoảng 10⁸ tới 10⁹ phép tính đơn giản mỗi giây. Lấy 10⁸ làm con số an toàn để ước lượng.
| Độ phức tạp | n=1 000 | n=100 000 | n=10⁶ |
|---|---|---|---|
| O(n) | tức thì | tức thì | 0,01 giây |
| O(n log n) | tức thì | 0,02 giây | 0,2 giây |
| O(n²) | 0,01 giây | 100 giây | gần 3 giờ |
| O(n³) | 10 giây | quá lâu | quá lâu |
| O(2ⁿ) | quá lâu | quá lâu | quá lâu |
Tự đo để kiểm chứng
#include <stdio.h>
#include <time.h>
static long long dem_cap(const int *a, size_t n)
{
long long dem = 0;
for (size_t i = 0; i < n; ++i) {
for (size_t j = i + 1; j < n; ++j) {
if (a[i] + a[j] == 100) {
++dem;
}
}
}
return dem;
}
int main(void)
{
static int a[40000];
for (size_t i = 0; i < 40000; ++i) {
a[i] = (int)(i % 100);
}
for (size_t n = 5000; n <= 40000; n *= 2) {
clock_t t0 = clock();
/* Phải DÙNG kết quả, nếu không ở mức -O2 trình biên dịch
thấy nó bị vứt đi và xóa luôn cả hai vòng lặp. */
long long kq = dem_cap(a, n);
double giay = (double)(clock() - t0) / CLOCKS_PER_SEC;
printf("n = %6zu -> %.3f giây (đếm được %lld cặp)\n",
n, giay, kq);
}
return 0;
}n = 5000 -> 0.012 giây (đếm được 623750 cặp) n = 10000 -> 0.048 giây (đếm được 2497500 cặp) n = 20000 -> 0.191 giây (đếm được 9995000 cặp) n = 40000 -> 0.764 giây (đếm được 39990000 cặp)
Mỗi lần n tăng gấp đôi thì thời gian tăng khoảng bốn lần. Đó chính là dấu hiệu của O(n²), vì 2 bình phương bằng 4. Nếu là O(n) thì thời gian chỉ tăng hai lần, còn O(n³) thì tăng tám lần.
#Độ phức tạp không gian
Không chỉ thời gian, bộ nhớ cũng có độ phức tạp và cũng dùng ký hiệu Big-O. Nó tính bộ nhớ phụ, tức là phần cấp thêm ngoài dữ liệu vào.
/* O(1) bộ nhớ phụ: chỉ vài biến, không phụ thuộc n */
long long tong(const int *a, size_t n)
{
long long s = 0;
for (size_t i = 0; i < n; ++i) {
s += a[i];
}
return s;
}
/* O(n) bộ nhớ phụ: cấp một mảng mới cỡ n */
int *sao_chep(const int *a, size_t n)
{
int *b = malloc(n * sizeof *b);
for (size_t i = 0; i < n; ++i) {
b[i] = a[i];
}
return b;
}
/* O(n) bộ nhớ phụ dù không có malloc:
mỗi lời gọi đệ quy chiếm một khung trên ngăn xếp */
long long giai_thua(int n)
{
if (n <= 1) {
return 1;
}
return n * giai_thua(n - 1);
}Đánh đổi giữa thời gian và bộ nhớ
/* Fibonacci đệ quy thuần
Thời gian O(2ⁿ), bộ nhớ O(n) */
long long fib(int n)
{
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}/* Fibonacci có nhớ kết quả
Thời gian O(n), bộ nhớ O(n) */
long long fib_nho(int n, long long *bang)
{
if (n <= 1) {
return n;
}
if (bang[n] != -1) {
return bang[n];
}
bang[n] = fib_nho(n - 1, bang) + fib_nho(n - 2, bang);
return bang[n];
}Với n bằng 40, bản đầu gọi hàm hơn 331 triệu lần và mất khoảng vài giây. Bản thứ hai gọi 79 lần và cho kết quả tức thì. Cái giá là một mảng cỡ n. Đây là ví dụ điển hình của việc đánh đổi bộ nhớ lấy thời gian, và là ý tưởng nền của quy hoạch động ở Chương 28.
Tự làm thử
- Xác định Big-O cho bảy đoạn mã trong mục cách xác định nhanh, không nhìn chú thích.
- Viết chương trình đo thời gian với n, 2n và 4n, tính tỉ số để xác định bậc bằng thực nghiệm cho một thuật toán bất kỳ của bạn.
- Sửa vòng lặp gọi
strlentrong điều kiện, đo thời gian trước và sau với chuỗi dài một triệu ký tự. - Cho ràng buộc n tối đa là 200 000 và giới hạn một giây, cho biết những mức độ phức tạp nào chấp nhận được.
- Cài cả hai bản Fibonacci ở trên, đo thời gian với n bằng 35, 40 và 45. Ghi lại chênh lệch.
- Xác định độ phức tạp thời gian và không gian cho thuật toán sắp xếp nổi bọt ở Bài 7.3.
Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.
Tóm tắt
- Big-O mô tả tốc độ tăng của số phép tính khi dữ liệu lớn dần, bỏ hằng số và số hạng bậc thấp.
- Biến chạy cộng trừ thì O(n), nhân chia thì O(log n). Vòng lồng thì nhân, vòng nối tiếp thì cộng.
- Cẩn thận với lời gọi hàm trong điều kiện vòng lặp, ví dụ
strlenbiến O(n) thành O(n²). - Máy làm khoảng 10⁸ phép mỗi giây, dùng con số đó để chọn thuật toán từ ràng buộc của đề.
- Tỉ số thời gian khi n tăng gấp đôi cho biết bậc: khoảng 2 là O(n), 4 là O(n²), 8 là O(n³).
- Độ phức tạp không gian tính bộ nhớ phụ, và đệ quy chiếm O(độ sâu) bộ nhớ ngăn xếp.