B4534 桌面游戏(P6)— 解法全景与讲解

对当前目录下 981 份 p6.cpp 提交进行算法归类、原理剖析与最优写法推荐。这是一道 博弈论极小化极大(minimax)搜索题。

1. 题意重述

给定一个 3×3 棋盘,部分格子已填有互不相同的数字 1..90 表示空格。两位玩家 X 博士(先手)Y 博士(后手)轮流操作:

每一步:取出当前尚未使用的最小数字,把它放进任意一个空格。注意——放哪个数字是强制的(永远是剩余最小者),玩家唯一的选择是「放到哪个空格」。

双方轮流直到棋盘填满。计分:

X 博士希望 最大化 x − y,Y 博士希望 最小化 x − y,双方均最优决策。每个测试输出:x>yfirsty>xsecond,相等时 tie

输入:T1≤T≤20)组数据,每组 3 行 3 个整数。空格数 ≤ 9。数据规模:30% 数据空格 ≤2;60% 数据空格 ≤5。

举例:空格按行优先逐一被填入「当前最小可用数」,但 究竟哪个空格拿到这个数 由当前行动方按对自己最有利的方向选择。

2. 核心转化:一棵 minimax 博弈树

因为「这一步放哪个数」是被强制的(剩余最小),所以搜索树的每一层只在「把当前这个固定的数放进哪个空格」上分叉。设当前还剩 e 个空格:

谁先手由当前空格数的奇偶或一个传入的回合标志决定:X 先手,故第 0、2、4… 次填数是 X,第 1、3、5… 次是 Y。最坏情形 9 个空格,搜索树规模 ≤ 9! = 362880,乘以 T≤20 也仅约 7×10⁶ 个叶子,朴素 DFS 即可,无需记忆化或 α-β 剪枝(实测 20 组全空盘 < 0.2s)。

关键易错点:很多人把规则误读成「玩家可以自由选择放哪个数字」,于是在每个空格上枚举 1..9 所有剩余数字——这会把分支扩大、且改变博弈语义,结果错误。正确做法是每层只放那个被强制的最小数

3. 提交分布(981 份)

5
正确 minimax 搜索
0.5% · 真正满分
23
搜索/枚举但写错
2.3%
266
顺次填空 / 特判 ≤2 空
27.1% · 部分分
135
残缺 / 缺分支
13.8%
203
打表 / 抄错题 / 只输出 tie
20.7%
350
空程序 / 桩代码
35.7%

绿=正确 minimax · 蓝=搜索但写挂 · 黄=顺次填空/特判(部分分)· 橙=残缺 · 红=打表/抄错题 · 灰=空程序。分类由代码特征脚本(classify_final.py)自动统计,并对全部「搜索类」及代表性「填空类」提交编译后与暴力标程对拍验证;个别边界会有出入。

4. 一共出现了几类解法?

类别核心思路复杂度正确性 / 预期得分
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 分

5. 它们的区别在哪里?

C —— 「顺次填空」为什么只能拿部分分

这是最普遍的「看似对」错误(266 份,27%)。典型写法(如 D338C143A074):

// 把所有空格按从左到右、从上到下的顺序,依次填入剩余最小的数
for(每个空格 in 扫描顺序)
    for(int v=1; v<=9; v++) if(未用过 v){ 该空格=v; break; }
// 然后直接算 x、y 比较

只有 1 个空格时,那个数字和位置都唯一,没有任何选择,结果必然正确——这恰好覆盖 30% 的子任务。但只要 ≥2 个空格,玩家是可以选位置的,且 X 和 Y 目标相反。「按扫描顺序填」相当于假设双方都把数放在固定位置、毫不博弈,自然算错。
还有一批(如 A011A173)对 m==1 正确、对 m==2 手写特判,但 m==2 的特判往往也没正确实现「X 选位置后 Y 再选位置」的两层博弈,照样 WA。

B —— 有搜索,却写错了博弈语义

本次「带递归 / 全排列」的 23 份里,只有 5 份真正对。常见四种致命 bug:

对拍结论:A017 / A046 / A083 / A090 / A115 / A152 / A162 / A344 / B020 / B074 / B163 / D061 / D222 / E238 / E295 / A173 …WA(或编译失败)。其中 D061 在 20 组随机数据偶然通过、在覆盖全部空格数的 40 组数据上暴露错误——说明测试要覆盖各种空格数

6. 最优解法:朴素 minimax DFS(推荐)

数据规模 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) 的返回值 rr>0 → firstr<0 → secondr==0 → tie

参考实现(朴素 minimax,O(T·e!))

#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.cppA124/p6.cppB023/p6.cppD303/p6.cppE063/p6.cpp 即此类正确写法(与暴力标程在覆盖各空格数的对拍数据上完全一致),可作满分范例。乘积最大 9·8·7=504,三行之和 ≤ 1512,int 足够;用 long long 更稳妥。

7. 哪种写法「最优」?

结论:第 6 节的朴素 minimax DFS(每层放强制最小数、按层取 max/min)就是本题推荐最优写法。

方案时间好写吗评价
朴素 minimax DFSO(T·e!) ≤ 7×10⁶容易推荐:0.2s 内稳过,代码 30 行、不易错
minimax + 记忆化(map)更快但状态多本题完全没必要;状态/键设计稍错就 WA(见 A046)
minimax + α-β 剪枝更快同样没必要,徒增出错面
顺次填空 / 特判 ≤2 空O(1)仅 ≤1 空正确,约 30% 部分分
「存在性」全排列枚举O(e!):忽略对手会反向决策

本题 e≤9,朴素搜索的状态空间极小,记忆化与剪枝带来的收益可忽略,反而增加写错的风险。在竞赛/教学语境下,直接 DFS 是公认的标准满分解。

8. 常见失分点小结

最佳范例展示与点评 · 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;
}

点评

结论:抓住了"强制最小数字 + 极小化极大"这两个题眼,代码短、状态清楚,可作标程

学生案例点评 · E230 与 E118

E230/p6.cpp 仅特判 ≤2 空位 含 bug

#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 计分"两个子函数写对再递归。

E118/p6.cpp 固定输出

#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 空位的暴力枚举"也远胜固定输出。关键是抓住题眼:填入的数字是强制最小的,玩家只能选位置