語法書 / AA 競程語法書 下冊 / 第十五單元 / 對拍:全自動找出錯的測資(延伸知識)

15.7 對拍:全自動找出錯的測資(延伸知識)

手造測資靠清單,清單靠靈感——但總有靈感耗盡、測資造了十組還是抓不到錯的時候。這時就出動本單元的高潮:對拍(也叫 stress testing)。想法簡單得漂亮:既然不知道哪組測資會錯,那就讓電腦亂數生測資,一秒鐘測幾百組,直到撞出一組錯的為止。

圖 15-1:對拍流程——產生器、暴力解、你的解的三角對決

對拍需要三個程式互相配合(加上你的解共四支):

  1. 暴力解 brute:用最直白、最不會出錯的寫法把題目再解一次。它可以慢——反正測資很小;唯一的要求是你有十足把握它是對的
  2. 測資產生器 gen:亂數生出一組測資。
  3. 對拍主程式 duipai:無限循環「產測資 → 兩個解各自跑 → 比對輸出」,發現不一致就停下來,把肇事測資留給你。

第十四單元的檔案功夫在這裡全部用上:測資與輸出都走檔案,比對用讀檔。

案例:抓一個藏很深的 bug

題意:讀入 n 個正整數(保證不是所有數都相同),輸出嚴格第二大的數——比最大值小的數之中最大的那個。範例:輸入 43 1 4 2,輸出 3

你的解 sol.cpp,思路是 11.4 的 sort:

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

int main() {
    int n;
    cin >> n;
    vector<int> a(n);
    for (int i = 0; i < n; i++) cin >> a[i];
    sort(a.begin(), a.end());       // 排序後,倒數第二個就是第二大……吧?
    cout << a[n - 2] << '\n';
    return 0;
}

範例過了(排序成 1 2 3 4,倒數第二個是 3),提交——WA。手造了幾組測資也都對。上對拍。

第一件組:暴力解

放棄聰明的排序,改用兩趟直白掃描——先找最大值,再在「小於最大值」的數裡找最大。每一行都樸實到不可能寫錯:

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

int main() {
    int n;
    cin >> n;
    vector<int> a(n);
    for (int i = 0; i < n; i++) cin >> a[i];

    int mx = a[0];                          // 第一步:老老實實找最大值
    for (int x : a) mx = max(mx, x);

    int ans = -1;                           // 第二步:在「小於最大值」的數裡找最大
    for (int x : a) {
        if (x < mx) ans = max(ans, x);
    }
    cout << ans << '\n';
    return 0;
}

注意:對拍的「暴力解」不一定要是慢的解,重點是寫法直白到你有信心一定對——它是本次辦案的標準答案產生機,它要是也錯,整場對拍就白忙了。

第二件組:測資產生器

用 C++ 內建的亂數引擎 mt19937 生測資(名字來自它背後的演算法,把它當成一台品質很好的亂數販賣機就行——建好之後,每呼叫一次 rng() 就吐一個亂數):

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

int main() {
    mt19937 rng(clock());               // 每次執行用不同的種子
    int n = rng() % 5 + 2;              // 2 ~ 6 個數:測資越小,出錯時越好模擬
    vector<int> a(n);
    while (true) {                      // 題目保證「不是所有數都相同」
        for (int i = 0; i < n; i++) {
            a[i] = rng() % 5 + 1;       // 1 ~ 5 的小數字:容易撞出重複值
        }
        bool all_same = true;
        for (int x : a) {
            if (x != a[0]) all_same = false;
        }
        if (!all_same) break;           // 生出合法測資才離開
    }
    cout << n << '\n';
    for (int x : a) cout << x << ' ';
    cout << '\n';
    return 0;
}

三個設計決定,每個都有講究:

  • mt19937 rng(clock());——把 clock()(程式啟動至今的處理器時間,每次執行都不太一樣)當種子:種子不同,吐出的亂數序列就不同,所以每次執行會生出不同的測資% 5 + 1 把亂數壓進 1 \sim 5。若你的環境連續執行生出了相同測資(Windows 上特別常見,原因見下方補充),把種子換成 mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());——用「現在這一刻的時間戳」當種子,精細度通常在奈秒等級,連對拍這種一秒跑好幾輪的場景也不會重複。
  • 測資要小n 只到 6、數值只到 5。反直覺但重要——對拍的目的不是壓力測試,是找一組會錯的測資來模擬;測資越小,抓到時越好辦案。而且小數值容易撞出重複、相等這些邊界情況(15.6 清單的「全部相同」「已排序」在小範圍亂數下都會自然出現)。
  • 產生器要遵守題目的保證:題目說「不是所有數都相同」,產生器就得把全相同的組合重生——否則對拍會拿不合法的輸入抓到「不算錯的錯」,白白浪費你辦案時間。

第三件組:對拍主程式

指揮中心。system("指令") 會把字串交給作業系統、當成一行終端機指令執行——14.7 學的重導向直接寫在字串裡:

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

// 逐字比對兩個檔案的內容(用 >> 讀,順便無視行尾空白、換行的差異)
bool same_output(string file1, string file2) {
    ifstream f1(file1), f2(file2);
    vector<string> v1, v2;
    string s;
    while (f1 >> s) v1.push_back(s);
    while (f2 >> s) v2.push_back(s);
    return v1 == v2;                    // vector 可以直接用 == 比較內容
}

int main() {
    for (int round = 1; round <= 1000; round++) {
        int r1 = system("./gen > in.txt");                    // ❶ 產生一組測資
        int r2 = system("./sol < in.txt > out_sol.txt");      // ❷ 你的解跑它
        int r3 = system("./brute < in.txt > out_brute.txt");  // ❸ 暴力解也跑它
        if (r1 != 0 || r2 != 0 || r3 != 0) {                  // 有程式沒正常結束(RE)
            cout << "第 " << round << " 輪有程式執行失敗!測資就在 in.txt" << '\n';
            return 0;
        }
        if (!same_output("out_sol.txt", "out_brute.txt")) {   // ❹ 比對兩份輸出
            cout << "第 " << round << " 輪抓到不一致!測資就在 in.txt" << '\n';
            return 0;
        }
    }
    cout << "1000 輪全部通過,沒抓到反例" << '\n';
    return 0;
}

拆開看:same_output14.2ifstream 把兩個輸出檔逐字讀進兩個 vector<string>,再用 10.6== 一口氣比完——用 >> 讀的附帶好處是行尾空白、換行差異都被自動無視。主迴圈每輪四步:產測資、跑你的解、跑暴力解、比輸出;system 的回傳值順便幫我們把關——只要有程式沒正常結束(例如 RE),一樣停下來留測資。

開跑

四支程式放同一個資料夾,各自編譯:

對拍資料夾/
├── gen.cpp    → g++ -O2 -o gen gen.cpp        (產生器)
├── brute.cpp  → g++ -O2 -o brute brute.cpp    (暴力解)
├── sol.cpp    → g++ -O2 -o sol sol.cpp        (你的解)
└── duipai.cpp → g++ -O2 -o duipai duipai.cpp  (對拍主程式)

執行 ./duipai,本站實測:

執行結果:

第 1 輪抓到不一致!測資就在 in.txt

第一輪就撞到了(每次執行輪數不一定)。看看三個現場證物:

in.txt:        4
                2 4 1 4
out_sol.txt:   4
out_brute.txt: 2

接回 15.1:拿小測資辦案

對拍的任務到此完成——它交出了一組小到不能再小的鐵證,接下來回到 15.1 的心法二:模擬。排序後是 1 2 4 4a[n - 2] 是……4?原來如此:最大值出現兩次時,「倒數第二格」住的還是最大值,根本不是「嚴格第二大」。當初寫下 a[n - 2] 時的那個「想當然」,就這樣被一組四個數的測資戳破了。修正:從倒數第二格往前走,跳過所有等於最大值的數(題目保證不是全部相同,所以一定走得到):

    sort(a.begin(), a.end());
    int i = n - 2;
    while (a[i] == a[n - 1]) i--;   // 跳過所有和最大值一樣的數
    cout << a[i] << '\n';

修好後先餵剛才的 in.txt(輸出 2,與暴力解一致),再重跑一次 ./duipai

執行結果:

1000 輪全部通過,沒抓到反例

這才叫驗證完成(15.1 步驟 5)。要誠實地說:對拍一千輪全過不是數學證明——亂數測資蓋不到的角落永遠存在(所以 15.6 的手造邊界測資跟對拍是互補,不是替代)。但「暴力解陪跑一千輪毫無異議」的信心,跟「範例過了就交」完全是兩個世界。

動手試試看:翻出一題你以前 AC 的簡單題,故意把解答埋一個 bug(例如把某個 <= 改成 <),然後照本節架好三件組,看對拍幾輪內抓到它。親手抓過一次「自己埋的雷」,下次架對拍就不用回來翻書了。