Nhập môn

Thuật toán vét cạn (Brute Force): khi nào đủ qua test, mẹo khử vòng lặp và code C++, Python

Thuật toán vét cạn (Brute Force, hay duyệt toàn bộ — Complete Search) là phương pháp giải bài toán bằng cách liệt kê và kiểm tra mọi phương án có thể xảy ra để tìm ra đáp án đúng. Thay vì coi vét cạn là cách làm “ngây thơ”, các lập trình viên thi đấu luôn xem đây là bước khởi đầu bắt buộc để lấy trọn điểm subtask nhỏ, kiểm chứng hiểu đúng đề bài và kết hợp với biến đổi đại số nhằm cắt giảm số vòng lặp.

Ý chính
  • Vét cạn (Brute Force) cho kết quả đúng khi liệt kê đủ mọi ứng viên hợp lệ và kiểm tra, cập nhật đáp án đúng; chương trình vẫn có thể sai do tràn số hay đếm trùng.
  • Nhìn N để tính số cấu hình trước: 2²⁰ = 1.048.576, 400³ = 64.000.000, 5000² = 25.000.000. Đây là số lượng thao tác lý thuyết, không bảo đảm thời gian trên máy chấm.
  • Kỹ thuật tối ưu vét cạn mạnh nhất là khử biến phụ thuộc bằng đại số: nếu ẩn cuối cùng tính được từ các ẩn trước (như c = S - a - b), hãy bỏ hẳn vòng lặp của ẩn đó để giảm một bậc độ phức tạp.
  • Luôn giữ lại hàm vét cạn để làm bộ đối chiếu sinh test tự động (stress test) giúp bắt lỗi thuật toán tối ưu.

Thuật toán vét cạn (Brute Force) là gì và vì sao không được coi thường?

Vét cạn (Brute Force) — còn gọi là duyệt toàn bộ (Complete Search) — là chiến lược giải quyết bài toán theo nguyên tắc: “Thử tất cả các khả năng hợp lệ, phương án nào thỏa mãn điều kiện đề bài (hoặc tốt nhất) thì lấy.”

Ví dụ ngoài đời thực là chiếc khóa số 3 vòng (mỗi vòng từ 0 đến 9). Nếu quên mật khẩu, bạn xoay lần lượt từ 000 đến 999. Vì chỉ có 10 × 10 × 10 = 1.000 trạng thái, bạn có thể liệt kê hết các mã; thời gian thực tế tùy tốc độ thao tác. Trong các kỳ thi lập trình và phỏng vấn, vét cạn giữ 3 vai trò chiến lược:

  1. Lấy chắc điểm từng phần (Subtask): Nếu đề có nhóm điểm cho giới hạn nhỏ, vét cạn có thể giải được nhóm đó. Hãy đọc đúng bảng subtasks; không phải đề nào cũng chia điểm như nhau.
  2. Làm “máy dò lỗi” (Stress Testing) cho thuật toán tối ưu: Khi viết xong một thuật toán O(N log N) phức tạp, bạn có thể sinh ngẫu nhiên hàng nghìn bộ dữ liệu nhỏ (N ≤ 15) rồi cho chạy đối chiếu giữa hàm vét cạn và hàm tối ưu để tăng khả năng tìm phản ví dụ; kiểm ngẫu nhiên không chứng minh thuật toán đúng trên mọi đầu vào.
  3. Vét cạn thông minh đủ sức qua 100% test: Nhiều bài toán khi kết hợp khử biến hoặc thu hẹp miền giá trị sẽ co không gian tìm kiếm xuống còn vài triệu bước và đạt điểm tối đa (AC).

Nhìn giới hạn N để ước lượng số cấu hình

Giới hạn thời gian thay đổi theo đề; chi phí một bước cũng tùy ngôn ngữ, phép toán và máy chấm. Không có quy đổi cố định từ số bước sang một giây. Bảng dưới chỉ giúp so sánh quy mô tìm kiếm, chưa phải kết quả đo thời gian. Trước khi gõ code (bước 3 trong 7 bước tư duy giải quyết mọi bài toán), hãy đối chiếu giới hạn N với bảng sau:

Giới hạn của N trong đề bài Độ phức tạp và quy mô cần cân nhắc Dạng cấu trúc duyệt toàn bộ tương ứng
N ≤ 10 .. 11 O(N!) (10! = 3.628.800) Duyệt mọi hoán vị của N phần tử (next_permutation / itertools.permutations)
N ≤ 20 O(2ⁿ) (2²⁰ ≈ 10⁶) Duyệt mọi tập con của N phần tử (đệ quy quay lui hoặc mặt nạ bit)
N ≤ 400 O(N³) (400³ = 6,4 × 10⁷) Ba vòng lặp for lồng nhau (duyệt mọi bộ ba chỉ số (i, j, k))
N ≤ 5.000 O(N²) (5000² = 2,5 × 10⁷) Hai vòng lặp for lồng nhau (duyệt mọi cặp (i, j) hoặc mọi đoạn con [L, R])
N ≤ 10⁶ .. 10⁷ O(N) Một vòng lặp duy nhất (Tìm kiếm tuyến tính)
N ≤ 10¹² .. 10¹⁴ O(√N) (√10¹² = 10⁶) Vòng lặp chỉ chạy tới căn bậc hai (i * i <= N, như liệt kê ước số)

Nếu đề bài cho N ≤ 2.500 mà bạn dùng 3 vòng lặp lồng nhau O(N³) (2500³ ≈ 1,56 × 10¹⁰ bước), số cấu hình rất lớn, có nguy cơ quá thời gian (TLE) và bạn cần giảm đi ít nhất 1 vòng lặp.

Kỹ thuật 1: Khử vòng lặp thừa bằng biến đổi đại số — Từ O(N³) xuống O(N²)

Xét bài toán kinh điển sau:

Bài toán 1: Cho hai số nguyên không âm N và S (N ≤ 2.500). Hãy đếm số bộ ba số nguyên (a, b, c) thỏa mãn 0 ≤ a, b, c ≤ N và a + b + c = S.

  • Cách 1 — Vét cạn ngây thơ 3 vòng lặp O(N³): Cho 3 vòng lặp a, b, c chạy từ 0 đến N, kiểm tra if (a + b + c == S). Với N = 2.500, phải xét (2500 + 1)³ = 15.643.757.501 bộ ba, thường quá lớn cho một lời giải thi đấu.
  • Cách 2 — Khử vòng lặp c bằng đại số O(N²): Khi hai vòng lặp ngoài đã cố định a và b, biến c không còn tự do nữa mà được xác định duy nhất bởi: \[c = S - a - b\] Ta bỏ hẳn vòng lặp c, tính thẳng int c = S - a - b; trong O(1) rồi chỉ cần kiểm tra xem c có nằm trong đoạn hợp lệ 0 ≤ c ≤ N hay không!
Vét cạn ngây thơ: O(N³) Duyệt cả 3 biến a, b, c từ 0 .. N Kiểm tra: a + b + c == S 2001³ = 8.012.006.001 bộ ba Khử vòng lặp thừa: O(N²) Chỉ duyệt 2 biến a, b từ 0 .. N Tính thẳng: c = S - a - b 2001² = 4.004.001 cặp So sánh số cấu hình, chưa phải thời gian (N + 1)³ bộ ba → (N + 1)² cặp, mỗi cặp suy ra một c
1. Vét cạn 3 vòng lặp: O(N³) Duyệt cả a, b, c từ 0 .. N 2001³ = 8.012.006.001 bộ ba 2. Khử vòng lặp c: O(N²) Duyệt a, b rồi tính c = S - a - b 2001² = 4.004.001 cặp Số cấu hình khi N = 2000 Từ 8.012.006.001 bộ ba còn 4.004.001 cặp (a, b)
Hình 1. Khử vòng lặp thứ ba bằng công thức c = S - a - b giúp giảm số bước tính toán ở N = 2000 từ khoảng 8 tỷ bộ ba xuống khoảng 4 triệu cặp; số đếm chính xác là (N + 1)³ và (N + 1)².

Với N = 2.500, hai vòng lặp xét đúng (N + 1)² = 6.255.001 cặp (a, b). Bài không công bố thời gian chạy vì chưa có phép đo trên máy chấm:

#include <iostream>
using namespace std;

// Cách 1: Vét cạn ngây thơ 3 vòng lặp lồng nhau O(N^3)
long long dem_bo_ba_3_vong(int n, int s) {
    long long dem = 0;
    for (int a = 0; a <= n; ++a) {
        for (int b = 0; b <= n; ++b) {
            for (int c = 0; c <= n; ++c) {
                if (a + b + c == s) {
                    ++dem;
                }
            }
        }
    }
    return dem;
}

// Cách 2: Khử vòng lặp c bằng biến đổi đại số c = s - a - b -> giảm còn O(N^2)
long long dem_bo_ba_2_vong(int n, int s) {
    long long dem = 0;
    for (int a = 0; a <= n; ++a) {
        for (int b = 0; b <= n; ++b) {
            int c = s - a - b;
            if (0 <= c && c <= n) {
                ++dem;
            }
        }
    }
    return dem;
}

int main() {
    // Test 1: N = 2, S = 2 -> 6 bộ: (0,0,2), (0,1,1), (0,2,0), (1,0,1), (1,1,0), (2,0,0)
    cout << "N=2, S=2 -> 3vong: " << dem_bo_ba_3_vong(2, 2)
         << " | 2vong: " << dem_bo_ba_2_vong(2, 2) << "\n";

    // Test 2: N = 10, S = 15 -> 91 bộ
    cout << "N=10, S=15 -> 3vong: " << dem_bo_ba_3_vong(10, 15)
         << " | 2vong: " << dem_bo_ba_2_vong(10, 15) << "\n";

    // Test 3: N = 50, S = 75 -> 1951 bộ
    cout << "N=50, S=75 -> 3vong: " << dem_bo_ba_3_vong(50, 75)
         << " | 2vong: " << dem_bo_ba_2_vong(50, 75) << "\n";

    return 0;
}

Kỹ thuật 2: Thu hẹp miền duyệt bằng bất đẳng thức — Từ O(N²) xuống O(√N)

Ngoài việc giảm số vòng lặp, ta còn có thể cắt ngắn chiều dài vòng lặp nhờ bất đẳng thức. Xét bài toán thứ hai:

Bài toán 2: Cho số nguyên dương N lớn tới 10¹². Hãy liệt kê mọi cặp số nguyên dương (x, y) với 1 ≤ x ≤ y sao cho x² + y² = N.

Quá trình thu hẹp miền duyệt diễn ra qua 3 cấp độ:

  1. Cấp 1 — Duyệt 2 vòng x, y từ 1 .. N (O(N²)): Tốn 10²⁴ bước, bất khả thi.
  2. Cấp 2 — Khử y bằng căn bậc hai (O(N)): Tính y = √(N - x²) và kiểm tra y * y == N - x². Nhưng x chạy tới 10¹² vẫn bị TLE.
  3. Cấp 3 — Chặn cận trên của x (O(√N)): Vì x ≤ y nên x² ≤ y², suy ra 2x² ≤ x² + y² = N. Vậy vòng lặp x chỉ cần chạy chừng nào 2 * x * x <= N! Với N = 10¹², x chỉ chạy tối đa 707.106 bước; đây là cận số vòng lặp, không phải số đo thời gian.
Phương pháp duyệt trên N = 10¹² Độ phức tạp Tổng số bước lặp Nhận xét
Duyệt 2 vòng x, y từ 1 .. N O(N²) 10²⁴ bước Không thể chạy
Duyệt x từ 1 .. N, tính y = √(N - x²) O(N) 10¹² bước Quá thời gian (TLE)
Duyệt x thỏa 2x² ≤ N, tính y = √(N - x²) O(√N) ≈ 707.106 bước Cần chạy thử theo giới hạn đề
#include <cmath>
#include <iostream>
#include <utility>
#include <vector>
using namespace std;

// Tìm mọi cặp nguyên dương (x, y) với 1 <= x <= y sao cho x^2 + y^2 = n
// Thu hẹp miền duyệt: vì x <= y nên 2 * x^2 <= n -> chỉ tốn O(sqrt(n)) bước
vector<pair<long long, long long>> tim_cap_binh_phuong(long long n) {
    vector<pair<long long, long long>> ket_qua;
    for (long long x = 1; 2 * x * x <= n; ++x) {
        long long phan_con = n - x * x;
        long long y = (long long)round(sqrt((double)phan_con));
        if (y >= x && y * y == phan_con) {
            ket_qua.push_back({x, y});
        }
    }
    return ket_qua;
}

void in_nghiem(long long n) {
    auto ds = tim_cap_binh_phuong(n);
    cout << "N=" << n << ": ";
    if (ds.empty()) {
        cout << "NONE\n";
        return;
    }
    for (size_t i = 0; i < ds.size(); ++i) {
        cout << "(" << ds[i].first << "," << ds[i].second << ")"
             << (i + 1 == ds.size() ? "\n" : " ");
    }
}

int main() {
    in_nghiem(25); // N=25: (3,4)
    in_nghiem(50); // N=50: (1,7) (5,5)
    in_nghiem(65); // N=65: (1,8) (4,7)
    in_nghiem(7);  // N=7: NONE
    return 0;
}

Lỗi hay gặp khi viết thuật toán Vét cạn

Cẩn thận
  • Quên kiểm tra miền hợp lệ của biến được suy ra từ công thức. Khi tính c = S - a - b, nhiều bạn tăng biến đếm ngay mà quên kiểm tra if (0 <= c && c <= N), dẫn tới đếm nhầm cả nghiệm c âm hoặc c vượt quá N.
  • Tràn số int ở điều kiện dừng x * x <= N. Khi N ≈ 2 × 10⁹ mà x khai báo kiểu int 32-bit, tích x * x sẽ tràn thành số âm khi x vượt 46.340, gây lặp vô hạn. Hãy luôn khai báo long long x.
  • Đếm trùng cặp khi thứ tự không quan trọng. Khi duyệt các cặp chỉ số phân biệt {i, j}, hãy cho vòng trong bắt đầu từ for (int j = i + 1; j < n; ++j) thay vì j = 0.

Luyện ngay trên Big-O Coding

Thực hành ngay kỹ năng duyệt toàn bộ và rút gọn vòng lặp qua các bài tập trên hệ thống Big-O Coding:

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

Vét cạn (Brute Force) khác gì với Quay lui (Backtracking)?

Vét cạn là chiến lược xét toàn bộ ứng viên, có thể cài bằng vòng lặp hoặc đệ quy. Quay lui xây dựng nghiệm từng bước, thường bằng đệ quy, và có thể cắt nhánh khi biết nhánh đó không tạo được nghiệm hợp lệ.

Khi nào cần chuyển từ Vét cạn sang Chia để trị hoặc Tìm kiếm nhị phân?

Khi đã khử hết biến phụ thuộc mà độ phức tạp O(N²) vẫn tạo ra quá nhiều bước so với giới hạn của đề (ví dụ N = 100.000). Lúc đó hãy xem cấu trúc bài toán có cho phép dùng Tìm kiếm nhị phân, Chia để trị hoặc Quy hoạch động để giảm số bước hay không; không phải bài nào cũng có lời giải O(N log N) hoặc O(N).

Trong Python, vì sao nên dùng math.isqrt(n) thay cho int(math.sqrt(n))?

Với số nguyên lớn trên 10¹⁶, kiểu số thực float trong math.sqrt(n) bị mất độ chính xác các chữ số cuối. Hàm math.isqrt(n) tính căn bậc hai hoàn toàn trên số nguyên nên chính xác tuyệt đối với mọi số lớn.

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

Khóa Green cho người mới, sinh viên năm 1–2: xây nền tảng lập trình và thuật toán vững chắc, biết dùng AI hỗ trợ. 16 buổi online, học thử buổi 1 miễn phí.

Xem Khóa Green

Nguồn tham khảo

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