Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 7.522 phút đọc

Độ 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

Big-O
Cách mô tả tốc độ tăng của số phép tính khi kích thước dữ liệu tăng. Nó bỏ qua hằng số và bỏ qua các số hạng bậc thấp, vì chỉ quan tâm hành vi khi dữ liệu lớn.

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.

Với n nhỏ mọi đường gần như trùng nhau. Khác biệt chỉ lộ ra khi n lớn, và khi đó nó rất lớn.

#Bảng các mức phổ biến

Ký hiệuTênn=10n=1 000n=10⁶
O(1)Hằng số111
O(log n)Logarit31020
O(n)Tuyến tính1010³10⁶
O(n log n)Tuyến tính nhân log3310⁴2 × 10⁷
O(n²)Bình phương10010⁶10¹²
O(n³)Lập phương1 00010⁹10¹⁸
O(2ⁿ)Hàm mũ1 024quá lớnquá lớn
O(n!)Giai thừa3,6 × 10⁶quá lớnquá lớn
MứcVí 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ọng

Quy 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, 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ạpn=1 000n=100 000n=10⁶
O(n)tức thìtức thì0,01 giây
O(n log n)tức thì0,02 giây0,2 giây
O(n²)0,01 giây100 giâygần 3 giờ
O(n³)10 giâyquá lâuquá lâu
O(2ⁿ)quá lâuquá lâuquá lâu

Tự đo để kiểm chứng

do-thoi-gian.c
#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;
}
terminal
gcc -std=c17 -O2 do-thoi-gian.c -o do && ./do
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ớ

Ít bộ nhớ, chậm
/* 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);
}
Tốn bộ nhớ, nhanh
/* 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ử

  1. 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.
  2. 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.
  3. Sửa vòng lặp gọi strlen trong điều kiện, đo thời gian trước và sau với chuỗi dài một triệu ký tự.
  4. 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.
  5. 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.
  6. 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ụ strlen biế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.