15.7 對拍:全自動找出錯的測資(延伸知識)
手造測資靠清單,清單靠靈感——但總有靈感耗盡、測資造了十組還是抓不到錯的時候。這時就出動本單元的高潮:對拍(也叫 stress testing)。想法簡單得漂亮:既然不知道哪組測資會錯,那就讓電腦亂數生測資,一秒鐘測幾百組,直到撞出一組錯的為止。
對拍需要三個程式互相配合(加上你的解共四支):
- 暴力解
brute:用最直白、最不會出錯的寫法把題目再解一次。它可以慢——反正測資很小;唯一的要求是你有十足把握它是對的。 - 測資產生器
gen:亂數生出一組小測資。 - 對拍主程式
duipai:無限循環「產測資 → 兩個解各自跑 → 比對輸出」,發現不一致就停下來,把肇事測資留給你。
第十四單元的檔案功夫在這裡全部用上:測資與輸出都走檔案,比對用讀檔。
¶案例:抓一個藏很深的 bug
題意:讀入 n 個正整數(保證不是所有數都相同),輸出嚴格第二大的數——比最大值小的數之中最大的那個。範例:輸入 4 與 3 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_output 用 14.2 的 ifstream 把兩個輸出檔逐字讀進兩個 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 4,a[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(例如把某個 <= 改成 <),然後照本節架好三件組,看對拍幾輪內抓到它。親手抓過一次「自己埋的雷」,下次架對拍就不用回來翻書了。