对当前目录下 981 份 p6.cpp 提交进行算法归类、原理剖析与最优写法推荐。这是一道 博弈论极小化极大(minimax)搜索题。
给定一个 3×3 棋盘,部分格子已填有互不相同的数字 1..9,0 表示空格。两位玩家 X 博士(先手)与 Y 博士(后手)轮流操作:
双方轮流直到棋盘填满。计分:
x = 三行各自乘积之和;y = 三列各自乘积之和。X 博士希望 最大化 x − y,Y 博士希望 最小化 x − y,双方均最优决策。每个测试输出:x>y 时 first,y>x 时 second,相等时 tie。
输入:T(1≤T≤20)组数据,每组 3 行 3 个整数。空格数 ≤ 9。数据规模:30% 数据空格 ≤2;60% 数据空格 ≤5。
举例:空格按行优先逐一被填入「当前最小可用数」,但 究竟哪个空格拿到这个数 由当前行动方按对自己最有利的方向选择。
因为「这一步放哪个数」是被强制的(剩余最小),所以搜索树的每一层只在「把当前这个固定的数放进哪个空格」上分叉。设当前还剩 e 个空格:
e=0)→ 计算 x − y 作为叶子返回值。谁先手由当前空格数的奇偶或一个传入的回合标志决定:X 先手,故第 0、2、4… 次填数是 X,第 1、3、5… 次是 Y。最坏情形 9 个空格,搜索树规模 ≤ 9! = 362880,乘以 T≤20 也仅约 7×10⁶ 个叶子,朴素 DFS 即可,无需记忆化或 α-β 剪枝(实测 20 组全空盘 < 0.2s)。
绿=正确 minimax · 蓝=搜索但写挂 · 黄=顺次填空/特判(部分分)· 橙=残缺 · 红=打表/抄错题 · 灰=空程序。分类由代码特征脚本(classify_final.py)自动统计,并对全部「搜索类」及代表性「填空类」提交编译后与暴力标程对拍验证;个别边界会有出入。
| 类别 | 核心思路 | 复杂度 | 正确性 / 预期得分 |
|---|---|---|---|
| A. 正确 minimax DFS | 每层放强制最小数,枚举放入哪个空格,按层奇偶取 max/min,叶子算 x−y |
O(T·e!),e≤9 | 完全正确,100 分。实测仅 5 份对拍全过 |
| B. 搜索 / 全排列枚举但写错 | 有递归或 next_permutation,但语义错:见下方四类典型 bug |
O(e!) 或更大 | WA:约 23 份,多数能过 ≤1 空的样例分 |
| C. 顺次填空 / 特判少空格 | 把空格按扫描顺序依次填最小数,或只特判 0/1/2 个空格 | O(1)~O(T) | 只在 ≤1 空时正确(此时无人可选);≥2 空即错,约拿 30% 部分分 |
| D. 残缺提交 | 只写了 first 一条分支、漏读、循环边界写错、未填空就计分 |
— | 基本 0 分或仅样例分 |
| E. 打表 / 抄错题 / 恒定输出 | 读完输入只输出固定的 tie/second,或贴了 P1/P5 的代码 |
— | 0 分 |
| F. 空程序 / 桩 | int main(){return 0;} 或仅有框架 |
— | 0 分 |
这是最普遍的「看似对」错误(266 份,27%)。典型写法(如 D338、C143、A074):
// 把所有空格按从左到右、从上到下的顺序,依次填入剩余最小的数
for(每个空格 in 扫描顺序)
for(int v=1; v<=9; v++) if(未用过 v){ 该空格=v; break; }
// 然后直接算 x、y 比较
当只有 1 个空格时,那个数字和位置都唯一,没有任何选择,结果必然正确——这恰好覆盖 30% 的子任务。但只要 ≥2 个空格,玩家是可以选位置的,且 X 和 Y 目标相反。「按扫描顺序填」相当于假设双方都把数放在固定位置、毫不博弈,自然算错。
还有一批(如 A011、A173)对 m==1 正确、对 m==2 手写特判,但 m==2 的特判往往也没正确实现「X 选位置后 Y 再选位置」的两层博弈,照样 WA。
本次「带递归 / 全排列」的 23 份里,只有 5 份真正对。常见四种致命 bug:
A017 在每个空格上枚举所有剩余数字 none[i] 而非只放最小的那个——把题目读成「数字也能选」,博弈树完全错。B074 用 get_good 把所有叶子或起来(只要存在一种填满方式让 X 赢就输出 first);A083 预生成全部 9! 个满盘,匹配到任意一个 first 局面就判 first。这等价于「单人最优化」,忽略了对手会反向选择。A090 用 next_permutation 枚举,却把行/列乘积写成了求和,且只对 X 单方最大化 x−y(无 min 层)。A046 带一个「轮次」偏移的 map 记忆化,状态与返回值耦合错误,对拍多处不符。对拍结论:A017 / A046 / A083 / A090 / A115 / A152 / A162 / A344 / B020 / B074 / B163 / D061 / D222 / E238 / E295 / A173 … 均 WA(或编译失败)。其中 D061 在 20 组随机数据偶然通过、在覆盖全部空格数的 40 组数据上暴露错误——说明测试要覆盖各种空格数。
数据规模 e≤9 ⇒ 搜索树 ≤ 9! ≈ 3.6×10⁵,T≤20,总叶子 ≤ 7×10⁶。直接 DFS、不剪枝即可稳过,代码也最短最不易错。递归式(设 turn 从 0 计,偶数轮是 X=取 max,奇数轮是 Y=取 min):
dfs(turn):
v = 当前剩余的最小未用数字 // 强制,唯一
若不存在(棋盘已满) return x − y // 叶子
best = (turn 偶) ? −∞ : +∞
for 每个空格 (i,j):
放入 a[i][j] = v
r = dfs(turn+1)
撤销 a[i][j] = 0
best = (turn 偶) ? max(best,r) : min(best,r)
return best
顶层 dfs(0) 的返回值 r:r>0 → first,r<0 → second,r==0 → tie。
#include <bits/stdc++.h>
using namespace std;
int a[3][3];
int eval(){ // 行积之和 − 列积之和
int x = a[0][0]*a[0][1]*a[0][2] + a[1][0]*a[1][1]*a[1][2] + a[2][0]*a[2][1]*a[2][2];
int y = a[0][0]*a[1][0]*a[2][0] + a[0][1]*a[1][1]*a[2][1] + a[0][2]*a[1][2]*a[2][2];
return x - y;
}
int dfs(int turn){ // turn 偶 = X(max),奇 = Y(min)
bool used[10] = {};
for(int i=0;i<3;i++) for(int j=0;j<3;j++) used[a[i][j]] = true;
int v = -1;
for(int k=1;k<=9;k++) if(!used[k]){ v = k; break; } // 强制取最小未用数
if(v == -1) return eval(); // 棋盘已满 → 叶子
int best = (turn%2==0) ? INT_MIN : INT_MAX;
for(int i=0;i<3;i++) for(int j=0;j<3;j++) if(a[i][j]==0){
a[i][j] = v; // 放入这个空格
int r = dfs(turn+1);
a[i][j] = 0; // 回溯
if(turn%2==0) best = max(best, r);
else best = min(best, r);
}
return best;
}
int main(){
int T; cin >> T;
while(T--){
for(int i=0;i<3;i++) for(int j=0;j<3;j++) cin >> a[i][j];
int r = dfs(0); // X 先手
cout << (r>0 ? "first" : r<0 ? "second" : "tie") << "\n";
}
return 0;
}
本目录中 E047/p6.cpp、A124/p6.cpp、B023/p6.cpp、D303/p6.cpp、E063/p6.cpp 即此类正确写法(与暴力标程在覆盖各空格数的对拍数据上完全一致),可作满分范例。乘积最大 9·8·7=504,三行之和 ≤ 1512,int 足够;用 long long 更稳妥。
结论:第 6 节的朴素 minimax DFS(每层放强制最小数、按层取 max/min)就是本题推荐最优写法。
| 方案 | 时间 | 好写吗 | 评价 |
|---|---|---|---|
| 朴素 minimax DFS | O(T·e!) ≤ 7×10⁶ | 容易 | 推荐:0.2s 内稳过,代码 30 行、不易错 |
| minimax + 记忆化(map) | 更快但状态多 | 中 | 本题完全没必要;状态/键设计稍错就 WA(见 A046) |
| minimax + α-β 剪枝 | 更快 | 中 | 同样没必要,徒增出错面 |
| 顺次填空 / 特判 ≤2 空 | O(1) | 易 | 仅 ≤1 空正确,约 30% 部分分 |
| 「存在性」全排列枚举 | O(e!) | 中 | 错:忽略对手会反向决策 |
本题 e≤9,朴素搜索的状态空间极小,记忆化与剪枝带来的收益可忽略,反而增加写错的风险。在竞赛/教学语境下,直接 DFS 是公认的标准满分解。
x−y,Y 后手最小化;层的奇偶 / 回合标志要对应正确。x、y 计算错(A090 即此)。>、<、== 三种,漏掉 tie 分支会错。A124/p6.cpp解法最优 风格最佳 经与独立 minimax 标程对拍(120 组覆盖 0~9 空位全部一致),这份是 5 份全对提交里结构最清晰的极小化极大搜索。
#include<bits/stdc++.h>
using namespace std;
int a[3][3];
int dfs(bool fir){
int cnt[10] = {};
for(int i = 0; i < 3; i++)
for(int j = 0; j < 3; j++)
cnt[a[i][j]]++;
int ok = -1;
for(int i = 1; i <= 9; i++)
if(!cnt[i]){
ok = i;
break;
}
if(ok == -1){
int x = 0, y = 0;
for(int i = 0; i < 3; i++){
int tmp = 1;
for(int j = 0; j < 3; j++)
tmp *= a[i][j];
x += tmp;
}
for(int i = 0; i < 3; i++){
int tmp = 1;
for(int j = 0; j < 3; j++)
tmp *= a[j][i];
y += tmp;
}
return x - y;
}
if(!fir){
int ans = -1e9;
for(int i = 0; i < 3; i++)
for(int j = 0; j < 3; j++)
if(a[i][j] == 0){
a[i][j] = ok;
ans = max(ans, dfs(!fir));
a[i][j] = 0;
}
return ans;
}
else{
int ans = 1e9;
for(int i = 0; i < 3; i++)
for(int j = 0; j < 3; j++)
if(a[i][j] == 0){
a[i][j] = ok;
ans = min(ans, dfs(!fir));
a[i][j] = 0;
}
return ans;
}
}
int main(){
int t;
cin >> t;
for(int i = 0; i < t; i++){
for(int j = 0; j < 3; j++)
for(int k = 0; k < 3; k++)
cin >> a[j][k];
int ans = dfs(false);
if(ans == 0)
cout << "tie" << endl;
else if(ans < 0)
cout << "second" << endl;
else
cout << "first" << endl;
}
return 0;
}
ok 填入——严格遵守"填入的数字是强制最小、玩家只能选位置"的规则。这正是本题最容易写错的地方(很多人误以为玩家可自选数字)。dfs(bool fir) 表示轮到谁:一方取 max、另一方取 min,叶子直接返回 x-y,主函数按符号判 first/second/tie——极小化极大的范式写得干净利落。fir 语义略反(主函数传 false 才是先手 X 取 max),改成 bool isX 或 bool maximize 更直观;每个结点重算 cnt[10] 是 O(9) 的小浪费,可把"当前该填的数字"作为参数传下去。结论:抓住了"强制最小数字 + 极小化极大"这两个题眼,代码短、状态清楚,可作标程。
#include<bits/stdc++.h>
using namespace std;
const int N=4;
int a[N][N];
string check() {
int t[10]= {0};
for(int i=1; i<=3; i++) {
for(int j=1; j<=3; j++) {
t[a[i][j]]++;
}
}
if(t[0]>=3) {
return "tie";
}
if(t[0]==2) {
int x=0,y=0;
for(int i=1; i<=9; i++) {
if(t[i]==0) {
if(x==0) {
x=t[i];
} else {
y=t[i];
}
}
}
int a1[4][4],a2[4][4];
int tmp=0;
for(int i=1; i<=3; i++) {
for(int j=1; j<=3; j++) {
if(a[i][j]!=0) {
a1[i][j]=a[i][j];
a2[i][j]=a[i][j];
} else {
if(tmp==0) {
a1[i][j]=x;
a2[i][j]=y;
} else {
a1[i][j]=y;
a2[i][j]=x;
}
}
}
}
int sum1=0,sum2=0;
for(int i=1; i<=3; i++) {
int p1=1,p2=1;
for(int j=1; j<=3; j++) {
p1*=a1[i][j];
p2*=a1[j][i];
}
sum1+=p1,sum2+=p2;
}
int sum_1=0,sum_2=0;
for(int i=1; i<=3; i++) {
int p1=1,p2=1;
for(int j=1; j<=3; j++) {
p1*=a2[i][j];
p2*=a2[j][i];
}
sum_1+=p1,sum_2+=p2;
}
if(sum2>sum1||sum_2>sum_1) {
return "second";
} else if(sum2<sum1&&sum_2<sum_1) {
return "first";
} else {
return "tie";
}
} else if(t[0]<2) {
int tmp;
for(int i=1; i<10; i++) {
if(t[i]==0) {
tmp=t[i];
break;
}
}
for(int i=1; i<=3; i++) {
for(int j=1; j<=3; j++) {
if(a[i][j]==0) {
a[i][j]=tmp;
}
}
}
int sum1=0,sum2=0;
for(int i=1; i<=3; i++) {
int p1=1,p2=1;
for(int j=1; j<=3; j++) {
p1*=a[i][j];
p2*=a[j][i];
}
sum1+=p1,sum2+=p2;
}
if(sum1>sum2) {
return "first";
} else if(sum1==sum2) {
return "tie";
} else {
return "second";
}
}
}
int main() {
int T;
cin>>T;
while(T--) {
for(int i=1; i<=3; i++) {
for(int j=1; j<=3; j++) {
cin>>a[i][j];
}
}
cout<<check()<<endl;
}
return 0;
}
思路:按空位个数分类讨论:t[0]≥3 直接 "tie";==2 手动枚举两个缺失数字的两种填法;<2 把唯一缺失数字填入后算分。
不足:① 没有博弈/极小化极大,空位 ≥3 一律 "tie",60%/100% 数据必错。② bug:x=t[i]、tmp=t[i] 取的是计数值(=0),应是数字 i,于是填进格子的是 0。③ ==2 时用 sum2>sum1||sum_2>sum_1 把两种摆法混在一起判,不符合"双方各自最优"。
如何提高:放弃分类讨论,直接写极小化极大 DFS——每层填"当前最小未用数字"(强制规则),先手取 max、后手取 min(见本页最佳范例 A124)。先把"取最小数字"和"x、y 计分"两个子函数写对再递归。
#include<bits/stdc++.h>
using namespace std;
int main(){
int t;
cin>>t;
for(int i=1;i<=t;i++)
cout<<"tie\n";
return 0;
}
思路:无论输入一律输出 "tie"。
不足:非解法,只能蒙中恰好平局的测试点。
如何提高:同上,按 A124 写极小化极大 DFS;即便先实现"≤2 空位的暴力枚举"也远胜固定输出。关键是抓住题眼:填入的数字是强制最小的,玩家只能选位置。