0

Bài toán - Thuật toán - Độ phức tạp của thuật toán

đã đăng vào 31, Tháng 7, 2026, 21:41

BÀI TOÁN - THUẬT TOÁN - ĐỘ PHỨC TẠP CỦA THUẬT TOÁN

Chào các bạn học sinh và lập trình viên! Để học tốt môn Tin học và đạt điểm cao trong các kỳ thi Lập trình thi đấu (Competitive Programming) trên hệ thống Sen Code Online Judge!, việc nắm vững ba khái niệm nền tảng: Bài toán, Thuật toánĐộ phức tạp của thuật toán là điều kiện tiên quyết.

Bài viết này sẽ cung cấp cho các bạn một cái nhìn toàn diện từ lý thuyết cơ bản đến các ví dụ thực hành cụ thể bằng ngôn ngữ C++ để các bạn dễ dàng tự học và áp dụng.


I. BÀI TOÁN

1. Khái niệm bài toán trong lập trình

Trong toán học, chúng ta đã quen với các bài toán yêu cầu tìm nghiệm của phương trình, chứng minh định lý hoặc tính diện tích hình học. Trong Tin học (lập trình), khái niệm Bài toán rộng hơn rất nhiều.

Bài toán là một yêu cầu xử lý thông tin từ một tập dữ liệu đầu vào (gọi là Input) để thu được kết quả đầu ra mong muốn (gọi là Output).

Ví dụ:

  • Bài toán Tìm số lớn nhất:
    • Input: Một danh sách gồm N số nguyên.
    • Output: Giá trị lớn nhất trong danh sách đó.
  • Bài toán Sắp xếp:
    • Input: Một dãy số chưa được sắp xếp.
    • Output: Dãy số đó sau khi đã được xếp thứ tự tăng dần.
2. Ngôn ngữ lập trình và máy tính

Máy tính điện tử thực chất là một thiết bị phần cứng cực kỳ phức tạp nhưng nó chỉ hiểu được một ngôn ngữ duy nhất gọi là Ngôn ngữ máy (Machine Code). Ngôn ngữ máy là các lệnh được biểu diễn hoàn toàn dưới dạng các chuỗi nhị phân (gồm ký tự 01).

Vì con người không thể viết trực tiếp hàng triệu dòng lệnh nhị phân một cách chính xác, các Ngôn ngữ lập trình (Programming Languages) (như C++, Python, Java, Pascal...) ra đời. Chúng sử dụng các từ khóa tiếng Anh và các ký hiệu toán học gần gũi với tư duy con người để viết mã nguồn.

3. Thông dịch và Biên dịch

Để máy tính hiểu và chạy được mã nguồn viết bằng ngôn ngữ lập trình, hệ thống cần một "người phiên dịch" để chuyển mã nguồn đó sang mã máy. Có hai cơ chế phiên dịch chính:

a. Biên dịch (Compilation)
  • Khái niệm: Trình biên dịch (Compiler) sẽ quét qua toàn bộ mã nguồn của chương trình, kiểm tra lỗi cú pháp, sau đó dịch toàn bộ chương trình thành một tệp thực thi mã máy (ví dụ tệp .exe trên Windows) trước khi chạy.
  • Đại diện tiêu biểu: C, C++, Rust, Go.
  • Ưu điểm: Chương trình chạy cực kỳ nhanh vì mã máy đã được chuẩn bị sẵn, không tốn thời gian dịch trong lúc chạy. Đây là lý do C++ luôn là ngôn ngữ được lựa chọn hàng đầu trong lập trình thi đấu.
b. Thông dịch (Interpretation)
  • Khái niệm: Trình thông dịch (Interpreter) sẽ đọc mã nguồn đến đâu thì dịch câu lệnh đó sang mã máy và thực thi ngay lập tức đến đó (dịch từng dòng một).
  • Đại diện tiêu biểu: Python, JavaScript, Ruby.
  • Ưu điểm: Dễ dàng phát triển, chạy thử đoạn mã ngắn nhanh chóng mà không cần qua bước cài đặt biên dịch phức tạp.
  • Nhược điểm: Tốc độ thực thi chậm hơn biên dịch rất nhiều (thường chậm hơn từ 10 đến 100 lần) vì máy tính vừa phải dịch vừa phải chạy.

II. THUẬT TOÁN

1. Khái niệm thuật toán trong lập trình

Để giải quyết một bài toán trên máy tính, chúng ta không thể ra lệnh chung chung mà phải chỉ ra từng bước thực hiện rõ ràng. Hệ thống các bước đó được gọi là Thuật toán.

Thuật toán (Algorithm) là một dãy hữu hạn các chỉ dẫn rõ ràng, có thứ tự, sao cho khi thực hiện chúng theo đúng trình tự từ dữ liệu đầu vào (Input), ta sẽ thu được kết quả đầu ra mong muốn (Output).

2. Các đặc trưng quan trọng của một thuật toán

Một thuật toán đúng nghĩa phải đảm bảo đủ 4 tính chất sau:

  1. Tính xác định: Mỗi bước của thuật toán phải hoàn toàn rõ ràng, một nghĩa. Hai máy tính độc lập khi thực hiện cùng một bước trên cùng dữ liệu phải cho ra kết quả hoàn toàn giống nhau.
  2. Tính hữu hạn: Thuật toán phải dừng lại và đưa ra kết quả sau một số hữu hạn bước tính. Nếu thuật toán rơi vào vòng lặp vô tận (không bao giờ dừng), đó là một thuật toán lỗi.
  3. Tính đúng đắn: Thuật toán phải đưa ra Output chính xác cho mọi trường hợp Input hợp lệ của bài toán.
  4. Tính hiệu quả: Thuật toán chạy càng nhanh (tốn ít thời gian) và sử dụng càng ít tài nguyên bộ nhớ thì càng được đánh giá cao.
3. Các phương pháp mô tả thuật toán

Chúng ta có thể biểu diễn các bước của thuật toán bằng:

  • Ngôn ngữ tự nhiên: Liệt kê các bước thực hiện bằng văn bản tiếng Việt.
  • Sơ đồ khối (Flowchart): Sử dụng các hình vẽ tiêu chuẩn (hình oval, hình chữ nhật, hình thoi...) kết hợp mũi tên để chỉ luồng thực thi dữ liệu.
  • Mã giả (Pseudocode): Viết bằng ngôn ngữ lai giữa ngôn ngữ tự nhiên và từ khóa lập trình để mô tả logic cốt lõi.
  • Mã nguồn lập trình: Viết thuật toán trực tiếp bằng các ngôn ngữ như C++ để máy tính chạy.

III. ĐỘ PHỨC TẠP CỦA THUẬT TOÁN (ALGORITHM COMPLEXITY)

Để đánh giá một thuật toán có hiệu quả hay không, các nhà khoa học máy tính sử dụng thước đo toán học gọi là Độ phức tạp của thuật toán. Thước đo này đánh giá hiệu năng dựa trên sự tăng trưởng của số phép tính hoặc lượng bộ nhớ tiêu thụ khi kích thước dữ liệu đầu vào (N) tiến tới vô cùng.

Ký hiệu Big-O (ký hiệu O) được sử dụng để biểu diễn giới hạn trên (trường hợp xấu nhất - Worst-case) của độ phức tạp thuật toán.

Về mặt toán học:

Ta nói một hàm f(N) = O(g(N)) nếu tồn tại hai hằng số dương c và n0 sao cho: f(N) <= c * g(N) với mọi N >= n0

Nói một cách trực quan: Khi N tăng lên cực kỳ lớn, ta chỉ giữ lại thành phần tăng trưởng nhanh nhất và bỏ qua mọi hằng số nhân hay các số hạng bậc thấp hơn.

  • Ví dụ 1: Số phép tính thực tế là f(N) = 3 * N2 + 5 * N + 10.
    • Khi N rất lớn, thành phần N2 sẽ lấn át hoàn toàn N và 10.
    • Bỏ qua hằng số nhân 3, ta thu được độ phức tạp là O(N2).
  • Ví dụ 2: Số phép tính thực tế là f(N) = 100 * log_2(N) + 7.
    • Bỏ qua hằng số 100 và 7, độ phức tạp là O(log N).
Bảng xếp hạng tốc độ tăng trưởng của các độ phức tạp thường gặp

Sắp xếp từ chạy nhanh nhất (tốt nhất) đến chạy chậm nhất (tệ nhất):

Ký hiệu Big-O Tên gọi Nhận xét trực quan
O(1) Độ phức tạp hằng số Cực kỳ nhanh, thời gian chạy không đổi khi N thay đổi.
O(log N) Độ phức tạp logarit Tăng rất chậm. Ví dụ: N = 1 tỷ thì log_2(N) chỉ khoảng 30 bước.
O(N) Độ phức tạp tuyến tính Thời gian chạy tăng tỉ lệ thuận với kích thước dữ liệu N.
O(N log N) Độ phức tạp tuyến tính - logarit Rất tối ưu cho sắp xếp dữ liệu lớn.
O(N2) Độ phức tạp bình phương Tương đối chậm. Thích hợp với dữ liệu vừa phải (N <= 5000).
O(N3) Độ phức tạp lập phương Rất chậm. Phù hợp dữ liệu nhỏ (N <= 500).
O(2N) Độ phức tạp lũy thừa Bùng nổ phép tính khi N tăng. Chỉ dùng với N <= 20.
O(N!) Độ phức tạp giai thừa Chậm nhất trong các loại. Chỉ dùng với N <= 10.

1. Các quy tắc xác định Big-O
a. Quy tắc cộng (Cho các khối lệnh chạy nối tiếp)

Nếu chương trình gồm nhiều đoạn mã chạy tuần tự, độ phức tạp của toàn bộ chương trình sẽ là độ phức tạp lớn nhất trong các đoạn đó. Đoạn A chạy O(f(N)), Đoạn B chạy O(g(N)) => Tổng thể chạy O(max(f(N), g(N)))

b. Quy tắc nhân (Cho các vòng lặp lồng nhau)

Nếu một vòng lặp chạy A lần, và bên trong nó thực hiện một khối lệnh có độ phức tạp O(B), thì tổng độ phức tạp sẽ là O(A * B).


2. Các ví dụ minh họa bằng C++ và phân tích chi tiết
a. Độ phức tạp hằng số O(1)

Thuật toán có độ phức tạp O(1) thực hiện một số lượng thao tác cố định, không phụ thuộc vào giá trị của dữ liệu đầu vào N.

#include <iostream>

using namespace std;

// Hàm tính tổng các số từ 1 đến N bằng công thức toán học
long long tinhTongNhanh(long long N) {
    long long tong = N * (N + 1) / 2; // (1) Phép toán cơ bản
    return tong;                      // (2) Trả về kết quả
}

int main() {
    long long N;
    if (cin >> N) {
        cout << tinhTongNhanh(N) << "
";
    }
    return 0;
}

Giải tích chi tiết:

  • Tại dòng lệnh (1), bộ vi xử lý thực hiện chính xác 3 phép toán số học: cộng (N + 1), nhân kết quả với N, và chia cho 2.
  • Cho dù N = 10 hay N = 1018 (1 tỷ tỷ), chương trình vẫn chỉ mất đúng 1 bước gán và 3 phép toán số học để ra kết quả.
  • Do số lượng phép tính là hằng số và độc lập hoàn toàn với N, độ phức tạp thời gian của thuật toán này là O(1).

b. Độ phức tạp tuyến tính O(N)

Độ phức tạp O(N) xảy ra khi chương trình phải duyệt qua toàn bộ dữ liệu đầu vào một hoặc một vài lần độc lập.

#include <iostream>
#include <vector>

using namespace std;

// Hàm tính tổng các số từ 1 đến N bằng vòng lặp
long long tinhTongTuanTu(long long N) {
    long long tong = 0;              // (1) 1 phép gán: O(1)
    for (int i = 1; i <= N; ++i) {   // (2) Vòng lặp chạy N lần
        tong += i;                   // (3) Phép cộng tích lũy: O(1)
    }
    return tong;                     // (4) Trả về kết quả
}

Giải tích chi tiết:

  • Dòng (1) tốn O(1) thời gian.
  • Vòng lặp (2) thực hiện lặp biến i từ 1 tới N. Với mỗi giá trị của i, câu lệnh tong += i ở dòng (3) được gọi một lần.
  • Lệnh tong += i tốn thời gian hằng số O(1). Vì nó lặp lại đúng N lần, tổng thời gian của vòng lặp là N * O(1) = O(N).
  • Theo quy tắc cộng: Tổng thời gian = O(1) + O(N) = O(N).
  • Nhận xét: Khi dữ liệu đầu vào N tăng lên gấp 10 lần thì số phép tính tăng lên 10 lần, tương ứng với đồ thị đường thẳng tuyến tính.

c. Độ phức tạp bình phương O(N2)

Đây là độ phức tạp kinh điển của các thuật toán sắp xếp sơ cấp (Bubble Sort, Selection Sort, Insertion Sort) sử dụng hai vòng lặp lồng nhau để so sánh cặp phần tử.

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// Thuật toán Sắp xếp nổi bọt (Bubble Sort)
void bubbleSort(vector<int> &A) {
    int n = A.size();
    for (int i = 0; i < n - 1; ++i) {        // Vòng lặp ngoài (1)
        for (int j = 0; j < n - i - 1; ++j) { // Vòng lặp trong (2)
            if (A[j] > A[j+1]) {              // So sánh (3)
                swap(A[j], A[j+1]);           // Đổi chỗ (4)
            }
        }
    }
}

Giải tích chi tiết:

  • Vòng lặp ngoài (1) chạy N-1 lần (với N là kích thước mảng).
  • Vòng lặp trong (2) chạy số lần phụ thuộc vào chỉ số i của vòng ngoài:
    • Khi i = 0, vòng trong lặp N - 1 lần.
    • Khi i = 1, vòng trong lặp N - 2 lần.
    • ...
    • Khi i = N - 2, vòng trong lặp 1 lần.
  • Tổng số lần phép so sánh và phép đổi chỗ thực thi là tổng của một cấp số cộng: S = (N-1) + (N-2) + ... + 1 = N * (N-1) / 2 = 0.5 * N<sup>2</sup> - 0.5 * N
  • Theo định nghĩa Big-O, ta bỏ qua hệ số tự do 0.5 và số hạng bậc thấp -0.5 * N, thu được kết quả O(N2).
  • Nhận xét: Nếu N = 105, chương trình cần thực hiện khoảng (105)2 / 2 = 5 * 109 (5 tỷ) phép so sánh. Trên máy tính hiện đại, thuật toán này sẽ chạy mất khoảng vài giây và chắc chắn bị TLE nếu giới hạn thời gian chấm là 1.0 giây.

d. Độ phức tạp logarit O(log N)

O(log N) là một độ phức tạp cực kỳ tối ưu. Nó thường xuất hiện khi phạm vi xử lý của thuật toán bị chia đôi (hoặc chia theo một tỉ lệ cố định) sau mỗi bước thực thi. Ví dụ tiêu biểu là Tìm kiếm nhị phân (Binary Search).

#include <iostream>
#include <vector>

using namespace std;

// Tìm kiếm nhị phân phần tử x trong mảng đã được sắp xếp tăng dần
int binarySearch(const vector<int> &A, int x) {
    int left = 0, right = A.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2; // (1) Tìm điểm giữa
        if (A[mid] == x) {
            return mid;                      // Tìm thấy x
        }
        if (A[mid] < x) {
            left = mid + 1;                  // Tìm ở nửa bên phải
        } else {
            right = mid - 1;                 // Tìm ở nửa bên trái
        }
    }
    return -1; // Không tìm thấy x
}

Giải tích chi tiết:

  • Ban đầu, không gian tìm kiếm chứa toàn bộ mảng gồm N phần tử.
  • Tại mỗi bước trong vòng lặp while, ta so sánh phần tử trung tâm A[mid] với x. Nhờ mảng đã được sắp xếp, ta có thể loại bỏ hoàn toàn một nửa mảng không chứa x.
    • Sau bước 1: Còn N / 2 phần tử cần xét.
    • Sau bước 2: Còn N / 4 phần tử cần xét.
    • ...
    • Sau bước k: Còn N / 2k phần tử cần xét.
  • Thuật toán sẽ dừng lại khi không gian tìm kiếm chỉ còn lại duy nhất 1 phần tử, tức là: N / 2<sup>k</sup> = 1 => 2<sup>k</sup> = N => k = log_2(N)
  • Số lần lặp tối đa của chương trình là log_2(N). Do đó, độ phức tạp thời gian là O(log N).
  • Nhận xét: Với N = 1 tỷ phần tử, tìm kiếm tuyến tính O(N) mất 1 tỷ phép so sánh, còn tìm kiếm nhị phân O(log N) chỉ mất tối đa 30 phép so sánh!

e. Độ phức tạp tuyến tính - logarit O(N log N)

Độ phức tạp này thường xuất hiện trong các thuật toán chia để trị tối ưu như Sắp xếp trộn (Merge Sort), Sắp xếp nhanh (Quick Sort) hoặc các thao tác lặp N lần mà mỗi lần thực hiện truy vấn mất thời gian O(log N) (ví dụ như chèn phần tử vào cây nhị phân tìm kiếm tự cân bằng như std::set, std::map trong C++).

#include <iostream>
#include <vector>
#include <algorithm> // Chứa hàm sắp xếp tối ưu std::sort

using namespace std;

int main() {
    int N;
    if (cin >> N) {
        vector<int> A(N);
        for (int i = 0; i < N; ++i) {
            cin >> A[i];
        }

        // C++ STL sort có độ phức tạp thời gian trung bình là O(N log N)
        sort(A.begin(), A.end());

        for (int i = 0; i < N; ++i) {
            cout << A[i] << (i == N - 1 ? "" : " ");
        }
        cout << "
";
    }
    return 0;
}

Giải tích chi tiết (Thông qua thuật toán Merge Sort):

  • Thuật toán Merge Sort hoạt động như sau:
    1. Chia: Đệ quy chia đôi mảng hiện tại thành 2 mảng con có kích thước bằng nhau. Việc chia này tạo ra một cây đệ quy có chiều cao tối đa là log_2(N) tầng.
    2. Trộn (Merge): Tại mỗi tầng của cây đệ quy, ta thực hiện gộp các mảng con đã được sắp xếp lại với nhau. Chi phí để gộp các mảng ở mỗi tầng có tổng kích thước N phần tử là O(N) phép tính.
  • Tổng độ phức tạp thời gian = (Số tầng cây đệ quy) * (Chi phí gộp tại mỗi tầng) = log_2(N) * O(N) = O(N log N).
  • Nhận xét: Với N = 105, thuật toán sắp xếp nổi bọt O(N2) mất khoảng 5 * 109 (5 tỷ) phép tính, còn std::sort tốn khoảng 105 * log_2(105) = 1.7 * 106 phép tính (chỉ mất chưa đầy 0.02 giây).

f. Độ phức tạp lũy thừa O(2N)

Đây là độ phức tạp rất kém hiệu quả. Số lượng phép tính sẽ tăng gấp đôi mỗi khi kích thước dữ liệu đầu vào N tăng thêm 1 đơn vị. Thường gặp trong các bài toán quay lui sinh cấu hình nhị phân, duyệt toàn bộ tập con của tập hợp N phần tử.

#include <iostream>

using namespace std;

// Hàm đệ quy tính số Fibonacci thứ N (không tối ưu)
long long fibonacci(int n) {
    if (n <= 1) return n;                     // Trường hợp cơ sở
    return fibonacci(n - 1) + fibonacci(n - 2); // (1) Hai lời gọi đệ quy lồng nhau
}

int main() {
    int N;
    if (cin >> N) {
        cout << fibonacci(N) << "
";
    }
    return 0;
}

Giải tích chi tiết:

  • Để tính fibonacci(N), ta phải tính fibonacci(N-1)fibonacci(N-2). Nhìn vào cây cuộc gọi (Call Tree):
    • Tầng 0: 1 cuộc gọi fibonacci(N).
    • Tầng 1: 2 cuộc gọi fibonacci(N-1)fibonacci(N-2).
    • Tầng 2: 4 cuộc gọi (fibonacci(N-2), fibonacci(N-3)...).
    • Số lượng cuộc gọi đệ quy tại tầng thứ k là khoảng 2k.
  • Do cấu trúc phân nhánh đôi ở mỗi nút, tổng số nút trên cây đệ quy này xấp xỉ tỉ lệ với 2N. Do đó, độ phức tạp thời gian là O(2N).
  • Nhận xét: Với N = 40, số phép tính khoảng 240 = 1.1 * 1012. Chương trình C++ trên sẽ bị đơ và chạy mất vài phút đến cả tiếng đồng hồ! (Để tối ưu, chúng ta phải dùng kỹ thuật Quy hoạch động để đưa độ phức tạp về O(N)).

3. Độ phức tạp không gian (Space Complexity)

Bên cạnh thời gian chạy, bộ nhớ cũng là yếu tố cần kiểm soát chặt chẽ. Độ phức tạp không gian đo lường lượng bộ nhớ bổ sung mà thuật toán cần sử dụng ngoài dữ liệu đầu vào.

  • Bộ nhớ O(1): Thuật toán chỉ sử dụng một vài biến đơn lẻ (như int a, long long sum).
  • Bộ nhớ O(N): Thuật toán khai báo một mảng một chiều có kích thước N phần tử để lưu trữ dữ liệu phụ trợ.
  • Bộ nhớ O(N2): Thuật toán khai báo một ma trận hai chiều có kích thước N * N.

Cảnh báo về Đệ quy: Mỗi khi một hàm đệ quy được gọi, hệ thống sẽ lưu thông tin của hàm đó vào ngăn xếp gọi hàm (Call Stack). Nếu độ sâu đệ quy lớn nhất là D, thuật toán sẽ tiêu tốn thêm không gian bộ nhớ là O(D). Do đó, các thuật toán đệ quy sâu rất dễ gặp lỗi Stack Overflow (Tràn ngăn xếp) nếu không kiểm soát tốt bộ nhớ.


4. Mối liên hệ giữa Big-O và giới hạn thời gian trong lập trình thi đấu

Trong các kỳ thi Tin học trẻ, Học sinh giỏi hay các bài tập trên Sen Code Online Judge!, giới hạn thời gian tiêu chuẩn cho mỗi bài toán thường là 1.0 giây.

Theo thực nghiệm:

Một máy chủ chấm bài chuẩn có thể thực hiện được khoảng 108 (100 triệu) phép tính cơ bản trong 1 giây.

Dựa vào quy tắc này, khi đọc giới hạn đề bài (Constraints) cho kích thước dữ liệu N, các bạn có thể nhẩm tính ngay thuật toán có độ phức tạp bao nhiêu là phù hợp để đạt điểm tối đa (AC) mà không lo bị quá thời gian (TLE):

Kích thước đầu vào (N) Độ phức tạp tối đa cho phép Thuật toán gợi ý / Kỹ thuật áp dụng
N <= 10 O(N!) Quay lui vét cạn các hoán vị, tổ hợp.
N <= 20 O(2N) Quay lui sinh chuỗi nhị phân, duyệt tập con, Quy hoạch động trạng thái (Bitmask DP).
N <= 500 O(N3) Thuật toán Floyd-Warshall tìm đường đi ngắn nhất, nhân ma trận.
N <= 5000 O(N2) Quy hoạch động cơ bản, hai vòng lặp lồng nhau duyệt cặp.
N <= 105 đến 106 O(N log N) hoặc O(N) Sắp xếp mảng, tìm kiếm nhị phân, kỹ thuật con trỏ (Two Pointers), cấu trúc dữ liệu Segment Tree / Fenwick Tree.
N <= 107 O(N) Một vòng lặp đơn giản, thuật toán sàng nguyên tố, quy hoạch động tuyến tính.
N > 108 O(log N) hoặc O(1) Tìm kiếm nhị phân trên kết quả (Binary Search on Answer), công thức toán học, lũy thừa nhị phân nhanh.

Lời kết

Việc kết hợp hiểu rõ logic Bài toán, thiết kế giải pháp Thuật toán tối ưu và đánh giá trước Độ phức tạp giúp bạn hoàn toàn làm chủ mã nguồn của mình trước khi gửi bài chấm.

Hy vọng tài liệu này sẽ đồng hành giúp các bạn tự tin học tập và đạt thật nhiều điểm xanh lá (Accepted) tại Sen Code Online Judge!! Chúc các bạn học tập thật tốt!


Bình luận

Hãy đọc nội quy trước khi bình luận.


Không có bình luận tại thời điểm này.