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

Độ phức tạp và cách chọn

Sau bài này bạn sẽ làm được

  • Đọc và viết ký hiệu O lớn cho các đoạn mã đơn giản
  • So sánh tìm tuyến tính, tìm nhị phân và bảng băm
  • Chọn đúng cách tìm cho từng tình huống
  • Biết hằng số ẩn quan trọng thế nào khi n nhỏ

Ký hiệu O lớn nói về cách chi phí tăng khi dữ liệu lớn lên, và nó cố tình bỏ qua mọi hằng số. Điều đó làm nó rất hữu ích để so sánh thuật toán, và rất dễ gây hiểu nhầm khi dữ liệu nhỏ.

#Ký hiệu O lớn

O lớn
Viết f(n) = O(g(n)) nghĩa là tồn tại hằng số c và n0 sao cho f(n) <= c * g(n) với mọi n >= n0. Nói cách khác, g là cận trên của tốc độ tăng, bỏ qua hằng số nhân và bỏ qua các giá trị n nhỏ.
Độ phức tạpTên gọin = 1000 tốnn = 1000000 tốn
O(1)Hằng số11
O(log n)Lô ga rít1020
O(n)Tuyến tính1 nghìn1 triệu
O(n log n)Tuyến tính lô ga rít10 nghìn20 triệu
O(n bình phương)Bình phương1 triệu1 nghìn tỷ
O(2 mũ n)MũKhông tưởngKhông tưởng
O(n giai thừa)Giai thừaKhông tưởngKhông tưởng

#Đọc độ phức tạp từ mã

doc-ma.c
/* O(1): số thao tác không phụ thuộc n */
int dau_tien(const int *a, size_t n) { return n ? a[0] : -1; }

/* O(n): một vòng lặp chạy n lần */
long tong(const int *a, size_t n)
{
    long s = 0;

    for (size_t i = 0; i < n; ++i) s += a[i];

    return s;
}

/* O(n^2): hai vòng lồng nhau, mỗi vòng n lần */
int co_cap_bang_nhau(const int *a, size_t n)
{
    for (size_t i = 0; i < n; ++i)
        for (size_t j = i + 1; j < n; ++j)
            if (a[i] == a[j]) return 1;

    return 0;
}

/* O(log n): mỗi bước chia đôi phạm vi */
size_t tim_nhi_phan(const int *a, size_t n, int x);

/* O(n log n): vòng ngoài n lần, mỗi lần gọi một việc O(log n) */
void chen_het_vao_cay(BST *t, const int *a, size_t n)
{
    for (size_t i = 0; i < n; ++i) bst_chen(t, a[i]);
}

/* O(n + m): hai vòng lặp NỐI TIẾP, không lồng nhau */
void in_hai_mang(const int *a, size_t n, const int *b, size_t m)
{
    for (size_t i = 0; i < n; ++i) printf("%d ", a[i]);
    for (size_t j = 0; j < m; ++j) printf("%d ", b[j]);
}

#Ba cách tìm đặt cạnh nhau

Tuyến tínhNhị phânBảng băm
Độ phức tạp tìmO(n)O(log n)O(1) trung bình
Xấu nhấtO(n)O(log n)O(n)
Cần dữ liệu đã sắpKhôngCóKhông
Chi phí chuẩn bị0O(n log n) để sắpO(n) để dựng bảng
Bộ nhớ phụ00O(n)
Chèn một phần tửO(1)O(n) vì phải dịchO(1) khấu hao
Xóa một phần tửO(n)O(n)O(1) khấu hao
Duyệt theo thứ tựO(n log n) phải sắpO(n), rất nhanhKhông làm được
Truy vấn theo khoảngO(n)O(log n + k)Không làm được
n = 1 000 000 tốn500 nghìn phép20 phép1 phép
terminal
./do-ba-cach 1000000 1000000
1000000 phan tu, 1000000 lan tim

                     chuan bi      tim       tong
tuyen tinh            0.000 s   412.4 s    412.4 s
sap + nhi phan        0.089 s     0.142 s    0.231 s
bang bam              0.104 s     0.038 s    0.142 s
# Nhưng chỉ tìm 10 lần thì bức tranh đảo ngược
./do-ba-cach 1000000 10
1000000 phan tu, 10 lan tim

                     chuan bi      tim       tong
tuyen tinh            0.000 s   0.0041 s   0.0041 s
sap + nhi phan        0.089 s   0.0000 s   0.0890 s
bang bam              0.104 s   0.0000 s   0.1040 s

#Hằng số ẩn quan trọng thế nào

O lớn bỏ qua hằng số, và với n nhỏ thì hằng số quyết định tất cả. Đây là sai lầm phổ biến nhất khi áp dụng phân tích tiệm cận.

hang-so.c
/* Thuật toán A: O(n^2) với hằng số 1
   Thuật toán B: O(n log n) với hằng số 100

   n = 10   : A = 100,        B = 3320      -> A nhanh hơn 33 lần
   n = 100  : A = 10000,      B = 66400     -> A nhanh hơn 6.6 lần
   n = 1000 : A = 1000000,    B = 996000    -> hòa
   n = 10000: A = 100000000,  B = 13300000  -> B nhanh hơn 7.5 lần

   Điểm hòa vốn ở khoảng n = 1000. Dưới ngưỡng đó, thuật toán
   "tệ hơn về tiệm cận" lại nhanh hơn thật. */
terminal
./do-insertion-vs-quick
     n   insertion (O(n^2))   quick (O(n log n))   ben nao nhanh hon
     8            0.09 us              0.41 us   insertion
    16            0.28 us              0.72 us   insertion
    32            1.02 us              1.38 us   insertion
    64            3.81 us              2.94 us   quick
   128           14.90 us              6.12 us   quick
  1024          912.00 us             54.80 us   quick
Nguồn của hằng số ẩnẢnh hưởng
Truy cập bộ nhớ tuần tự hay ngẫu nhiênChênh nhau 10 tới 100 lần, xem Bài 21.1
Lời gọi hàm qua con trỏChậm hơn 3 tới 15 lần vì không nội tuyến được
Cấp phát độngMỗi lần malloc tốn hàng chục tới hàng trăm chu kỳ
Nhánh dự đoán saiMỗi lần sai tốn 15 tới 20 chu kỳ
Phép chia và chia dưChậm hơn phép cộng 20 tới 40 lần

#Chọn cách nào cho tình huống nào

Tình huốngChọnVì sao
Tìm một lần trong mảng chưa sắpTuyến tínhSắp xếp tốn O(n log n), không bù lại được
Tìm nhiều lần, chỉ cần khớp chính xácBảng bămO(1) và dựng bảng chỉ tốn O(n)
Tìm nhiều lần, cần cả truy vấn theo khoảngMảng đã sắp hoặc câyBảng băm không làm được truy vấn khoảng
Dữ liệu thay đổi liên tục, cần thứ tựCây cân bằngMảng đã sắp chèn tốn O(n)
Dữ liệu thay đổi liên tục, không cần thứ tựBảng bămChèn và xóa đều O(1) khấu hao
n dưới 50 và không đổi nhiềuMảng, tìm tuyến tínhHằng số nhỏ nhất, mã đơn giản nhất
Cần bảo đảm ở trường hợp xấu nhấtCây cân bằngBảng băm có thể tụt về O(n) khi bị tấn công

Bảng tra nhanh cho cả Phần 10 và 11

Cấu trúcTìmChènXóaDuyệt theo thứ tự
Mảng chưa sắpO(n)O(1) ở cuốiO(n)Phải sắp trước
Mảng đã sắpO(log n)O(n)O(n)O(n)
Danh sách liên kếtO(n)O(1) nếu có con trỏO(1) nếu có con trỏO(n)
Cây tìm kiếm cân bằngO(log n)O(log n)O(log n)O(n)
Bảng bămO(1) TBO(1) TBO(1) TBKhông làm được
HeapO(n)O(log n)O(log n) chỉ ở gốcKhông làm được

Tự làm thử

  1. Viết bảng thời gian ước tính cho sáu độ phức tạp với n từ 10 tới một tỷ, giả sử một tỷ thao tác mỗi giây.
  2. Với năm đoạn mã bạn từng viết, xác định độ phức tạp của từng đoạn và giải thích cách đếm.
  3. Đo thời gian ba cách tìm trên một triệu phần tử với số lần tìm bằng 1, 10, 100 và một triệu.
  4. Tìm điểm hòa vốn giữa insertion sort và quick sort trên máy bạn, rồi so với ngưỡng 16 mà nhiều thư viện dùng.
  5. Viết một hàm O(n) nhưng có hằng số lớn, và một hàm O(n log n) có hằng số nhỏ, rồi tìm n mà chúng đổi ngôi.

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

  • O lớn mô tả tốc độ tăng của chi phí, bỏ qua hằng số nhân và bỏ qua các giá trị n nhỏ.
  • Đọc độ phức tạp bằng cách đếm tổng số lần thân vòng lặp trong cùng chạy, không phải nhân số vòng lặp với nhau.
  • Tìm tuyến tính không cần chuẩn bị, tìm nhị phân cần sắp trước, bảng băm cần dựng bảng. Điểm hòa vốn thường chỉ vài chục lần tìm.
  • Hằng số ẩn quyết định tất cả khi n nhỏ, và bộ nhớ đệm là nguồn chênh lệch lớn nhất mà O lớn hoàn toàn mù trước nó.
  • Dùng O lớn để loại thuật toán không khả thi, nhưng phải đo để chọn giữa hai ứng viên hợp lý.