Thi đấu

Kỹ thuật sinh test và đối chiếu code (stress testing): tự tìm test làm sai lời giải trong vài giây

Bài nộp bị báo sai ở test ẩn mà bạn đọc đi đọc lại code vẫn không thấy lỗi: đây là tình huống ai thi lập trình cũng từng gặp. Stress testing là cách để máy tính tự tìm giúp bạn một test nhỏ làm lời giải sai, thường chỉ mất vài giây và khoảng 20 dòng code.

Bạn nộp bài, qua được ví dụ trong đề, nhưng máy chấm báo sai ở test thứ 7. Bạn không được xem test đó. Đọc lại code ba lần vẫn thấy logic hợp lý. Lúc này, cách hiệu quả nhất thường không phải đọc tiếp mà là để máy tính tự đi tìm một test làm chương trình sai.

Kỹ thuật này gọi là stress testing (sinh test và đối chiếu code). Ý tưởng rất đơn giản: viết thêm một lời giải chậm nhưng chắc chắn đúng, sinh thật nhiều test nhỏ ngẫu nhiên, chạy cả hai lời giải và dừng lại ngay khi chúng cho kết quả khác nhau. Test đó chính là chìa khóa để tìm lỗi. Bài viết này trình bày ba thành phần của một bộ stress test, một ví dụ cụ thể với bài đổi tiền, và những lưu ý để dùng kỹ thuật này trong phòng thi.

1. Ba thành phần của một bộ stress test

Thành phần Vai trò Yêu cầu
Lời giải cần kiểm tra Chương trình bạn định nộp, thường nhanh nhưng có thể sai. Giữ nguyên như bản nộp.
Lời giải đối chứng Chương trình chậm nhưng đúng chắc chắn: vét cạn, thử mọi khả năng, hoặc quy hoạch động không tối ưu. Càng đơn giản càng tốt. Không dùng chung hàm với lời giải cần kiểm tra.
Bộ sinh test Tạo dữ liệu ngẫu nhiên hợp lệ theo đề. Dữ liệu nhỏ (ví dụ \(N \le 8\), giá trị \(\le 10\)) để lời giải đối chứng chạy nhanh và test sai dễ đọc.

Điểm mấu chốt nằm ở cột cuối: test nhỏ. Nhiều người sinh test lớn vì nghĩ test lớn mới bắt được lỗi. Thực tế ngược lại: phần lớn lỗi (sai công thức, sai ca biên, sai chỉ số) đều xuất hiện ở dữ liệu rất nhỏ. Test nhỏ còn giúp bạn tính tay được đáp án đúng, điều không thể làm với mảng \(10^5\) phần tử.

Lời giải đối chứng thường chính là lời giải subtask đầu mà bạn đã viết để lấy điểm chắc, như trình bày trong bài Cách đọc đề và ăn điểm subtask. Vì vậy, viết lời giải vét cạn trước không bao giờ là phí thời gian.

2. Ví dụ: thuật toán tham lam đổi tiền sai ở đâu?

Đề bài: Có các loại đồng xu mệnh giá cho trước, mỗi loại dùng không giới hạn. Tìm số đồng xu ít nhất để tạo ra đúng số tiền \(S\).

Một ý tưởng tự nhiên là tham lam: luôn lấy đồng xu lớn nhất còn dùng được. Với hệ tiền như 1, 2, 5, 10, cách này cho kết quả đúng. Nhưng nó có đúng với mọi bộ mệnh giá không? Thay vì cố chứng minh, ta để máy kiểm tra bằng cách so với quy hoạch động — lời giải đối chứng chắc chắn đúng:

\[dp[0] = 0, \qquad dp[i] = \min_{c \in \text{mệnh giá},\ c \le i} \left( dp[i - c] + 1 \right)\]

Chương trình dưới đây duyệt số tiền từ 1 trở lên với bộ mệnh giá \(\{1, 3, 4\}\), chạy cả hai cách và dừng ở số tiền đầu tiên mà kết quả khác nhau:

#include <bits/stdc++.h>
using namespace std;

// Minh họa Stress Testing: phát hiện phản ví dụ nơi Tham lam đổi tiền bị sai so với Quy hoạch động
int greedy_coins(const vector<int>& coins, int amount) {
    vector<int> c = coins;
    sort(c.rbegin(), c.rend());
    int cnt = 0;
    for (int v : c) {
        cnt += amount / v;
        amount %= v;
    }
    return amount == 0 ? cnt : -1;
}

int dp_coins(const vector<int>& coins, int amount) {
    const int INF = 1e9;
    vector<int> dp(amount + 1, INF);
    dp[0] = 0;
    for (int i = 1; i <= amount; ++i) {
        for (int v : coins) {
            if (i >= v && dp[i - v] != INF) {
                dp[i] = min(dp[i], dp[i - v] + 1);
            }
        }
    }
    return dp[amount] == INF ? -1 : dp[amount];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, max_amt;
    vector<int> coins;
    if (!(cin >> n >> max_amt)) {
        coins = {1, 3, 4};
        max_amt = 10;
    } else {
        coins.resize(n);
        for (int i = 0; i < n; ++i) cin >> coins[i];
    }
    int first_diff = -1, g_ans = -1, d_ans = -1;
    for (int amt = 1; amt <= max_amt; ++amt) {
        int g = greedy_coins(coins, amt);
        int d = dp_coins(coins, amt);
        if (g != d) {
            first_diff = amt;
            g_ans = g;
            d_ans = d;
            break;
        }
    }
    if (first_diff == -1) {
        cout << "MATCH\n";
    } else {
        cout << "DIFF_AT=" << first_diff << " GREEDY=" << g_ans << " DP=" << d_ans << "\n";
    }
    return 0;
}

Kết quả chạy:

DIFF_AT=6 GREEDY=3 DP=2
Quy trình Stress Testing: Sinh Test Ngẫu Nhiên Bắt Lỗi Wrong Answer (WA) Đối chiếu tự động giữa Code Trâu (Brute-Force đúng 100%) và Code Tối Ưu trên hàng nghìn test nhỏ Vòng lặp Stress Test 4 bước trong phòng thi 1. Bộ sinh test (gen): Dùng mt19937_64 sinh N nhỏ (N = 5..10) để dễ nhìn bằng mắt khi bắt được lỗi. 2. Chạy song song: out1 = brute(test) và out2 = optimal(test). 3. So sánh: Nếu out1 != out2 -> Dừng ngay lập tức và in ra bộ test phản ví dụ nhỏ nhất! 4. Sửa bug trong optimal() rồi chạy lại 1000 vòng. Ví dụ bắt quả tang lỗi Tham lam đổi tiền Bộ mệnh giá xu: coins = {1, 3, 4} • Với amount = 1..5: Greedy == DP (Khớp) Phát hiện lệch tại amount = 6 (DIFF_AT=6): • Greedy: chọn 4 + 1 + 1 -> 3 tờ xu (SAI!) • DP chuẩn: chọn 3 + 3 -> 2 tờ xu (ĐÚNG!) Chỉ mất 0.01 giây để vạch trần giả thuyết tham lam sai.
Kỹ thuật Stress Testing Săn Lỗi WA Gen Test + Code Trâu vs Code Tối Ưu 1. Sinh test ngẫu nhiên kích thước nhỏ (N = 5..8) 2. Chạy hàm vét cạn brute() và hàm tối ưu sol() 3. Tự động dừng khi phát hiện brute() != sol() Sử dụng bộ sinh số 64-bit mt19937_64 chuẩn C++17 Phản ví dụ bắt được (coins = {1, 3, 4}): Tại tổng tiền amount = 6: • Tham lam (Greedy): 4 + 1 + 1 = 3 xu • Quy hoạch động : 3 + 3 = 2 xu -> Báo ngay: DIFF_AT=6 GREEDY=3 DP=2
Hình 1: Đối chiếu tham lam và quy hoạch động với mệnh giá 1, 3, 4: hai cách khớp nhau từ 1 đến 5, lệch lần đầu ở số tiền 6 khi tham lam dùng 4 + 1 + 1 (3 đồng) còn đáp án đúng là 3 + 3 (2 đồng).

Bảng đối chiếu từng số tiền:

Số tiền Tham lam chọn Số đồng (tham lam) Quy hoạch động Khớp?
1 1 1 1 có
2 1 + 1 2 2 có
3 3 1 1 có
4 4 1 1 có
5 4 + 1 2 2 có
6 4 + 1 + 1 3 2 (3 + 3) không

Chỉ trong 6 bước, máy đã tìm ra phản ví dụ nhỏ nhất. Nhìn vào test này, lỗi của tham lam hiện ra rõ ràng: lấy đồng 4 trước khiến phần còn lại là 2, phải trả bằng hai đồng 1; trong khi bỏ qua đồng 4 và dùng hai đồng 3 tốt hơn. Nếu chỉ thử với ví dụ trong đề (thường là các số như 7 hoặc 10), bạn có thể không bao giờ phát hiện ra.

Ví dụ này duyệt tuần tự vì không gian test nhỏ. Với các bài khác, ta thay vòng duyệt bằng bộ sinh ngẫu nhiên, như phần tiếp theo.

Học cùng giảng viên

Chủ đề này nằm trong Buổi 01 - Contest Strategy & Diagnostic Test của Tài năng lập trình. Xem đề cương

3. Khung stress test dùng lại cho mọi bài

Khung dưới đây viết bằng Python, gọi hai chương trình đã biên dịch (nhanh và cham), sinh dữ liệu ngẫu nhiên nhỏ và dừng ở test đầu tiên cho kết quả khác. Bạn chỉ cần sửa hàm sinh_test cho từng bài:

import random
import subprocess

def sinh_test(rng):
    n = rng.randint(1, 8)
    a = [rng.randint(-10, 10) for _ in range(n)]
    return f"{n}\n{' '.join(map(str, a))}\n"

def chay(lenh, du_lieu):
    kq = subprocess.run(lenh, input=du_lieu, capture_output=True, text=True, timeout=5)
    return kq.stdout.split()

rng = random.Random(2026)
for lan in range(1, 2001):
    test = sinh_test(rng)
    a = chay(["./nhanh"], test)
    b = chay(["./cham"], test)
    if a != b:
        print("SAI O LAN", lan)
        print(test)
        print("nhanh:", a, "cham:", b)
        break
else:
    print("2000 test deu khop")

Ba chi tiết đáng chú ý:

  • Cố định hạt giống ngẫu nhiên (random.Random(2026)). Khi tìm ra test sai và sửa code, bạn chạy lại với cùng hạt giống để chắc chắn test đó đã qua. Trong C++, dùng std::mt19937 rng(2026); thay cho rand().
  • So sánh theo từng từ (split()), không so cả chuỗi, để tránh báo sai vì khác dấu cách hay xuống dòng cuối.
  • Giới hạn thời gian (timeout=5) để phát hiện cả trường hợp lời giải nhanh bị lặp vô hạn.

4. Khi nào nên dùng và những lưu ý trong phòng thi

Stress testing đặc biệt hiệu quả trong các tình huống sau:

  • Bài tham lam: như ví dụ đổi tiền, tham lam thường đúng với test dễ nhưng sai ở trường hợp đặc biệt.
  • Bài nhiều ca biên: mảng toàn số âm, \(N = 1\), mọi phần tử bằng nhau, đồ thị không liên thông.
  • Lời giải có tối ưu phức tạp: cây phân đoạn, hai con trỏ, quy hoạch động có giảm chiều — chỉ cần sai một chỉ số là kết quả lệch.

Một số lưu ý để không mất thời gian vô ích:

  1. Lời giải đối chứng cũng có thể sai. Nếu hai chương trình lệch nhau, hãy tính tay đáp án của test đó trước khi kết luận bên nào sai.
  2. Thu nhỏ test khi tìm thấy. Nếu test sai có \(N = 8\), thử giảm giới hạn sinh xuống \(N \le 4\) rồi chạy lại. Test càng nhỏ, lỗi càng dễ nhìn.
  3. Chuẩn bị sẵn khung trước kỳ thi. Trong kỳ thi ICPC, nhiều đội in sẵn khung stress test trong tài liệu mang vào phòng thi để gõ trong 2–3 phút. Ở các kỳ thi cá nhân, bạn nên luyện gõ khung này thuộc lòng.
  4. Không thay thế việc suy nghĩ về độ phức tạp. Stress test với dữ liệu nhỏ chỉ kiểm tra tính đúng. Để chắc chắn không quá thời gian, hãy tự tạo một test lớn nhất theo ràng buộc và đo thời gian chạy.

Nếu đang luyện trên Codeforces, bạn có thể áp dụng ngay kỹ thuật này cho các bài bị sai sau kỳ thi; xem thêm Cách luyện Codeforces cho người mới. Danh sách các bước gỡ lỗi khác (đọc lại đề, kiểm tra tràn số, in giá trị trung gian) có trong trang Debugging Checklist của USACO Guide ở phần nguồn tham khảo.

Luyện ngay trên Big-O Coding

Ba bài dưới đây đều có lời giải tham lam dễ nghĩ ra nhưng dễ sai ở ca đặc biệt. Hãy viết thêm lời giải vét cạn và chạy đối chiếu trước khi nộp:

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

Lộ trình 4 cấp độ cho học sinh thi Tin học trẻ, Chuyên Tin, Olympic, ACM-ICPC: xếp lớp theo năng lực, mini contest, lớp Đội tuyển. Học online, phí theo tháng.

Xem Tài năng lập trình

Nguồn tham khảo

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