Nhập môn

Độ phức tạp thuật toán và ký hiệu Big-O: cách tính nhanh, ví dụ C++ và Python

Độ phức tạp thuật toán (algorithmic complexity) cho biết số phép tính và lượng bộ nhớ mà chương trình cần dùng tăng lên nhanh tới đâu khi kích thước dữ liệu đầu vào n lớn lên. Ký hiệu Big-O giữ lại bậc tăng trưởng lớn nhất trong trường hợp xấu nhất, giúp bạn nhìn giới hạn của đề bài là ước lượng được ngay chương trình có chạy kịp trong 1 giây hay không.

Ý chính
  • Khi tính Big-O, ta bỏ các hằng số nhân và chỉ giữ lại số hạng tăng nhanh nhất khi n tiến ra vô cùng (ví dụ 3n² + 5n + 10 được viết gọn là O(n²)).
  • Máy chấm bài trực tuyến thực hiện được khoảng 108 phép tính cơ bản trong 1 giây.
  • Nhìn giới hạn n trong đề bài là biết ngay độ phức tạp cần hướng tới: n ≤ 10³ dùng được O(n²), còn n = 10⁵ bắt buộc phải tìm thuật toán O(n log n) hoặc O(n).

Vì sao không đo tốc độ thuật toán bằng giây mà dùng Big-O?

Khi mới học lập trình và thuật toán, rất tự nhiên ta muốn bấm đồng hồ xem chương trình chạy hết bao nhiêu mili-giây. Tuy nhiên, thời gian chạy tính bằng giây phụ thuộc vào rất nhiều yếu tố bên ngoài thuật toán: máy tính dùng chip mới hay cũ, trình biên dịch có bật cờ tối ưu -O2 hay không, hoặc lúc đó máy có đang mở nhiều ứng dụng khác hay không.

Để đánh giá công bằng hai cách giải trên mọi máy tính, khoa học máy tính đếm số phép toán cơ bản (cộng, trừ, nhân, chia, so sánh, gán biến, truy cập một ô mảng) theo kích thước đầu vào n. Khi n còn nhỏ (như n = 10), cách làm dở và cách làm hay đều chạy xong trong chớp mắt. Sự khác biệt chỉ bộc lộ rõ rệt khi n tăng lên hàng trăm nghìn hoặc hàng triệu phần tử.

Ký hiệu Big-O (đọc là "O lớn") mô tả tốc độ tăng trưởng của số phép toán trong trường hợp xấu nhất (worst-case) khi n rất lớn.

Cùng một bài toán, ba độ phức tạp khác nhau một trời một vực

Giả sử ta cần tính tổng 1 + 2 + 3 + ... + n với n = 1000. Hãy đặt cạnh nhau 3 cách viết dưới đây và đếm chính xác số lần phép cộng được thực hiện:

#include <iostream>
using namespace std;

// Cách 1: Hai vòng lặp lồng nhau — O(n^2) phép cộng
long long tongCapN2(int n, long long& buoc) {
    long long s = 0;
    buoc = 0;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= i; ++j) {
            s += 1;
            buoc++;
        }
    }
    return s;
}

// Cách 2: Một vòng lặp — O(n) phép cộng
long long tongCapN(int n, long long& buoc) {
    long long s = 0;
    buoc = 0;
    for (int i = 1; i <= n; ++i) {
        s += i;
        buoc++;
    }
    return s;
}

// Cách 3: Công thức cấp số cộng — O(1)
long long tongCapO1(long long n, long long& buoc) {
    buoc = 1;
    return n * (n + 1) / 2;
}

int main() {
    long long b1 = 0, b2 = 0, b3 = 0;
    long long k1 = tongCapN2(1000, b1);
    long long k2 = tongCapN(1000, b2);
    long long k3 = tongCapO1(1000, b3);
    cout << k1 << " " << k2 << " " << k3 << "\n";  // 500500 500500 500500
    cout << b1 << " " << b2 << " " << b3 << "\n";  // 500500 1000 1
    return 0;
}

Cả ba hàm đều in ra kết quả đúng là 500500, nhưng số bước lặp bên trong hoàn toàn khác nhau:

Cách viết Ý tưởng Số bước khi n = 1.000 Số bước khi n = 100.000 Độ phức tạp
tongCapN2 Hai vòng lặp lồng nhau cộng từng đơn vị 500.500 bước ~5 × 109 bước (quá 1 giây) O(n²)
tongCapN Một vòng lặp cộng dồn từ 1 đến n 1.000 bước 100.000 bước (chạy tức thì) O(n)
tongCapO1 Dùng công thức n * (n + 1) / 2 1 bước 1 bước O(1)

Với n = 100.000, hàm O(n²) phải thực hiện khoảng 5 tỷ phép cộng và bị hệ thống chấm bài báo lỗi quá thời gian (TLE), trong khi hàm O(n) và O(1) trả kết quả ngay lập tức.

Ba quy tắc rút gọn biểu thức Big-O trong 10 giây

Giả sử bạn đếm được một thuật toán thực hiện chính xác T(n) = 4n² + 15n + 200 phép tính. Để suy ra ký hiệu Big-O, bạn chỉ cần áp dụng 3 quy tắc:

  1. Quy tắc 1 — Bỏ mọi hằng số nhân: O(7n) viết thành O(n); O(n² / 2) như trong hàm tongCapN2 ở trên (thực hiện n(n + 1) / 2 = 0.5n² + 0.5n bước) cũng viết thành O(n²).
  2. Quy tắc 2 — Chỉ giữ lại số hạng có bậc cao nhất: Khi n = 100.000, n² = 10¹⁰ lớn gấp trăm nghìn lần 15n, nên 15n và 200 không còn đáng kể. Do đó 4n² + 15n + 200 rút gọn thành O(n²).
  3. Quy tắc 3 — Cộng cho các đoạn nối tiếp, nhân cho các vòng lặp lồng nhau:
    • Hai vòng lặp đứng nối tiếp nhau: vòng đầu chạy O(n), vòng sau chạy O(n log n) → tổng cộng là O(n + n log n) = O(n log n).
    • Hai vòng lặp lồng nhau: vòng ngoài chạy n lần, mỗi lần vòng trong chạy log n bước → tổng cộng là O(n × log n) = O(n log n).
Học cùng giảng viên

Chủ đề này nằm trong Lecture 01 - Algorithmic Complexity (Độ phức tạp thuật toán) của Khóa Blue 2.0. Xem đề cương

Bảy bậc độ phức tạp thường gặp nhất từ nhanh đến chậm

Hình dưới đây xếp các độ phức tạp phổ biến theo tốc độ tăng của số phép tính khi kích thước dữ liệu n lớn dần:

Ngưỡng 1 giây (~10⁸ phép tính) O(2ⁿ) O(n²) O(n log n) O(n) O(log n) O(1) Kích thước đầu vào n tăng dần → Số phép tính
Ngưỡng 1s (~10⁸ bước) O(2ⁿ) O(n²) O(n log n) O(n) O(log n) O(1) Kích thước dữ liệu n →
Hình 1. Tốc độ tăng số phép tính của các bậc Big-O. Các đường nằm dưới vạch đỏ chạy kịp trong 1 giây khi n lớn.

Để nhận diện nhanh trong code, bạn hãy đối chiếu cách biến chạy thay đổi ở từng vòng lặp:

  • O(1) — Độ phức tạp hằng số: Số bước cố định không phụ thuộc n. Ví dụ: truy cập phần tử a[i], kiểm tra chẵn lẻ n % 2 == 0, hoặc tính tổng đoạn bằng mảng cộng dồn sau khi đã tiền xử lý.
  • O(log n) — Độ phức tạp logarit: Sau mỗi bước, phạm vi bài toán giảm đi một nửa (hoặc biến chạy nhân đôi x *= 2, chia đôi n /= 2). Với n = 1.024, vòng lặp nhân đôi chỉ chạy đúng 10 lần vì 210 = 1024. Tiêu biểu nhất là tìm kiếm nhị phân và thuật toán Euclid tìm ước chung lớn nhất.
  • O(√n) — Độ phức tạp căn bậc hai: Vòng lặp chạy với điều kiện i * i <= n. Với n = 1.024, vòng lặp chỉ chạy 32 lần. Thường gặp khi kiểm tra một số nguyên tố hoặc liệt kê các ước số của n.
  • O(n) — Độ phức tạp tuyến tính: Duyệt qua từng phần tử của mảng một số lần cố định. Tiêu biểu là tìm kiếm tuần tự, kỹ thuật hai con trỏ và cửa sổ trượt.
  • O(n log n) — Tuyến tính nhân logarit: Xuất hiện khi một vòng lặp n lần bao ngoài một thao tác O(log n), hoặc khi chia đôi mảng rồi gộp lại. Đây là độ phức tạp của các thuật toán sắp xếp nhanh như Merge Sort, std::sort (C++) và sorted() (Python).
  • O(n²) — Độ phức tạp bậc hai: Hai vòng lặp for lồng nhau cùng chạy từ 1 đến n. Thường gặp ở các thuật toán sắp xếp cơ bản (Bubble Sort, Insertion Sort, Selection Sort) hoặc khi duyệt mọi cặp phần tử (i, j).
  • O(2n) và O(n!) — Độ phức tạp hàm mũ / giai thừa: Số bước tăng bùng nổ khi sinh mọi tập con (2n) hoặc mọi hoán vị (n!) bằng thuật toán quay lui. Chỉ chạy được khi n rất nhỏ (n ≤ 20 với 2n và n ≤ 10 với n!).

Đoạn code dưới đây minh họa trực tiếp số bước chạy của các mẫu vòng lặp O(log n), O(√n) và O(n log n) khi n = 1024:

#include <iostream>
using namespace std;

int main() {
    int n = 1024;

    // 1. Vòng lặp nhân đôi biến chạy -> O(log n)
    int buocLog = 0;
    for (int x = 1; x < n; x *= 2) {
        buocLog++;
    }

    // 2. Vòng lặp kiểm tra đến căn bậc hai -> O(sqrt(n))
    int buocCan = 0;
    for (int i = 1; i * i <= n; ++i) {
        buocCan++;
    }

    // 3. Vòng ngoài n lần, vòng trong nhân đôi -> O(n log n)
    int buocNLogN = 0;
    for (int i = 1; i <= n; ++i) {
        for (int x = 1; x < n; x *= 2) {
            buocNLogN++;
        }
    }

    cout << buocLog << " " << buocCan << " " << buocNLogN << "\n";  // 10 32 10240
    return 0;
}

Bảng tra nhanh: Nhìn giới hạn N trong đề bài để chọn thuật toán

Trong hầu hết các kỳ thi lập trình và hệ thống chấm bài trực tuyến, giới hạn thời gian cho mỗi test thường là 1 giây (tương đương khoảng 107 đến 108 phép tính cơ bản). Vì vậy, trước khi bắt tay vào viết code, bạn hãy nhìn dòng "Giới hạn" (Constraints) của đề bài và tra bảng sau:

Giới hạn của n trong đề Độ phức tạp tối đa chạy kịp 1 giây Các hướng thuật toán nên nghĩ tới ngay
n ≤ 10 O(n!) Duyệt mọi hoán vị, quay lui toàn bộ trạng thái
n ≤ 20 O(2n) hoặc O(n · 2n) Quay lui sinh tập con, duyệt bitmask
n ≤ 500 O(n³) Ba vòng lặp lồng nhau, nhân ma trận, Floyd-Warshall
n ≤ 5.000 O(n²) Hai vòng lặp lồng nhau, sắp xếp cơ bản, quy hoạch động hai chiều
n ≤ 200.000 (2 × 105) O(n log n) hoặc O(n) Sắp xếp + tìm kiếm nhị phân, hai con trỏ, cửa sổ trượt, tham lam
n ≤ 10.000.000 (107) O(n) Duyệt một lần, mảng cộng dồn, sàng nguyên tố
n ≤ 10¹² O(√n) Duyệt ước đến căn bậc hai, phân tích thừa số nguyên tố
n ≤ 10¹⁸ O(log n) hoặc O(1) Tìm kiếm nhị phân, lũy thừa nhị phân, công thức toán học

Độ phức tạp bộ nhớ (Space Complexity) là gì?

Bên cạnh thời gian chạy (Time Complexity), mỗi bài toán còn có giới hạn bộ nhớ (thường là 256 MB). Độ phức tạp bộ nhớ đo lượng ô nhớ phụ thêm mà thuật toán cần cấp phát theo n:

  • Nếu chỉ dùng vài biến đơn lẻ (lo, hi, mid, tong): bộ nhớ phụ là O(1).
  • Nếu tạo thêm một mảng phụ pref gồm n phần tử kiểu long long (mỗi phần tử 8 byte): bộ nhớ là O(n). Với n = 10^6, mảng này tốn khoảng 8 MB — rất an toàn.
  • Nếu tạo một ma trận hai chiều a[10000][10000] kiểu int (4 byte): tổng số phần tử là 108, ngốn khoảng 400 MB và sẽ bị báo lỗi Memory Limit Exceeded (MLE — vượt quá bộ nhớ).
  • Với hàm đệ quy gọi sâu n tầng, mỗi tầng đang chờ trên ngăn xếp (call stack) cũng tiêu tốn một khung bộ nhớ, nên độ phức tạp bộ nhớ của nó là O(n) dù bạn không khai báo mảng nào.

Lỗi hay gặp khi tính độ phức tạp

Cẩn thận
  • Tưởng cứ có 2 vòng lặp lồng nhau là O(n²). Ở kỹ thuật hai con trỏ hoặc sàng nguyên tố Eratosthenes, dù có vòng lặp bên trong nhưng biến chạy không quay lại từ đầu hoặc nhảy bước j += i, nên tổng số bước chỉ là O(n) hoặc O(n log log n). Hãy đếm tổng số lần câu lệnh trong cùng được chạy, đừng chỉ đếm số chữ for hay while.
  • Quên chi phí ẩn của hàm thư viện bên trong vòng lặp. Các thao tác như nối xâu, xóa phần tử đầu của vector / list, hoặc tìm kiếm x in list trong Python đều mất O(n). Nếu đặt chúng bên trong một vòng lặp n lần, tổng độ phức tạp sẽ bị đẩy lên O(n²).
  • Truyền mảng/vector theo giá trị trong C++. Viết int tinh(vector<int> a) (thiếu dấu tham chiếu &) khiến máy tính phải chép lại toàn bộ n phần tử mỗi lần gọi hàm, biến một hàm O(1) hoặc O(log n) thành O(n). Luôn viết const vector<int>& a.

Luyện ngay trên Big-O Coding

Hãy áp dụng ngay cách nhìn giới hạn n để chọn thuật toán phù hợp cho các bài tập miễn phí dưới đây:

Câu hỏi thường gặp

O(1) có phải lúc nào cũng chỉ chạy đúng 1 phép tính không?

Không. O(1) nghĩa là số phép tính là một hằng số cố định không phụ thuộc vào n. Một hàm thực hiện cố định 50 phép cộng dù n = 10 hay n = 10^9 thì vẫn được gọi là có độ phức tạp O(1).

Cơ số của log trong O(log n) là cơ số mấy?

Trong khoa học máy tính, log n mặc định hiểu là logarit cơ số 2 (log2 n) do thuật toán chia đôi dữ liệu. Về mặt ký hiệu Big-O, mọi cơ số logarit chỉ chênh nhau một hằng số nhân (vì loga n = log2 n / log2 a), mà hằng số nhân thì được lược bỏ trong Big-O.

Vì sao cùng là O(n) nhưng có bài chạy nhanh, có bài chạy chậm hơn?

Vì Big-O đã lược bỏ hệ số hằng số đứng trước n và độ nặng nhẹ của từng phép tính. Một vòng lặp O(n) chỉ cộng số nguyên sẽ nhanh hơn nhiều so với một vòng lặp O(n) có phép chia lấy dư, cấp phát bộ nhớ động hoặc tra cứu bảng băm ở mỗi bước.

Khi nào thuật toán O(n²) lại chạy nhanh hơn O(n log n)?

Khi kích thước mảng n rất nhỏ (thường n ≤ 20). Lúc này hằng số phụ của thuật toán đơn giản như Insertion Sort rất nhỏ, không tốn chi phí gọi hàm đệ quy hay cấp phát mảng tạm như Merge Sort.

Muốn học bài bản cùng giảng viên?

Luyện phỏng vấn Google, Meta, Amazon theo Interview Patterns: 16 buổi online live, lớp 7–15 người, C++/Python/Java. Học thử buổi 1 miễn phí.

Xem Khóa Blue 2.0

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483