← Cấu trúc dữ liệu & Giải thuật

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:

Đồ thị các lớp Big-O: O(1) phẳng, O(log n) tăng chậm, O(n) tuyến tính, O(n log n) và O(n²) dốc dần lên khi n lớn.
Các lớp Big-O thường gặp - càng lên trên, chi phí phình càng nhanh khi n lớn dần.
LớpSố phépThờ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²)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

“Số phép” trong bảng là mô hình đồ chơi: ta đếm thao tác cơ bản để so SÁNH các lớp. Thuật toán thật còn dính hằng số, tốc độ bộ nhớ, cache… nên giây thật sẽ lệch con số này. Nhưng hình dạng đường cong tăng trưởng thì đúng y như vậy - và đó mới là điều Big-O nói.

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 2n100n đề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

Big-O không phải tất cả. Với n nhỏ, một thuật toán O(n²) hằng số bé có thể nhanh hơn O(n log n) hằng số to; cache và phần cứng cũng làm lệch kết quả thật. Hãy coi Big-O là la bàn cho dữ liệu lớn, không phải đồng hồ bấm giây chính xác từng mili-giây.
  • 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

Nhưng một thao tác là O(1) hay O(n) còn tuỳ dữ liệu được lưu thế nào trong bộ nhớ. Vì sao “thêm vào đầu” lại rẻ với cái này mà đắt với cái kia? Hẹn gặp ở bài Mảng & danh sách liên kết.

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 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.

Vì tìm nhị phân chỉ chạy được trên dữ liệu ĐÃ SẮP xếp. Nếu mảng chưa sắp, bạn phải sắp trước (tốn O(n log n)) rồi mới tìm được. Nó đáng công khi bạn tìm đi tìm lại nhiều lần trên cùng một mảng.

Trong Big-O, cơ số của log không quan trọng (đổi cơ số chỉ là nhân thêm một hằng số, mà hằng số thì bị bỏ). Nhưng với thuật toán “chia đôi mỗi bước” như tìm nhị phân, cơ số tự nhiên là 2: n = 1000 thì log₂(1000) ≈ 10 lần chia đôi là hết.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Big-O dùng để đo điều gì?

  1. 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. 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. 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. 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. 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. 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ờ).