引言
Chomp(巧克力游戏)是组合博弈论中的经典两人游戏:一块 m×n 的巧克力棋盘,左上角格子有毒。两名玩家轮流选择一块巧克力格子 (i,j),吃掉该格子及其右下方所有格子。被迫吃掉毒格子的玩家输掉游戏。
本文将用 Java 实现 Chomp 游戏及其 AI 求解器,核心算法基于Sprague-Grundy 理论与必败态(P-position)分析,揭示这个看似简单却蕴含深刻数学的游戏。
问题建模
游戏状态
用单调不增序列表示棋盘边界:
状态 = [h0, h1, ..., hn-1]
其中 hi 表示第 i 列剩余的高度(从底部算起),且 h0 ≥ h1 ≥ … ≥ hn-1。
游戏规则
选择第 i 列并减少到高度 h’(0 ≤ h’ < hi,且保持单调性),即更新 hi = h’ 并确保 hi ≥ hi+1。
关键性质
定理:在 m×n 的 Chomp 游戏中,先手必胜(除了 1×1 的平凡情况)。
证明思路:假设后手有必胜策略,先手可以第一步吃掉右下角 (m-1, n-1) 成为”伪后手”,如果这是必胜策略则矛盾;如果不是,则原后手的必胜策略不存在。这个策略窃取论证证明了先手必胜,但没有给出具体必胜策略。
核心算法:Sprague-Grundy 数 + 记忆化搜索
对于小棋盘,可以计算每个状态的 Grundy 数:
– Grundy(终止态) = 0
– Grundy(状态) = mex{ Grundy(所有后继状态) }
其中 mex(最小排斥值)是不在集合中的最小非负整数。
完整 Java 实现
import java.util.*;
/**
* Chomp(巧克力游戏)求解器
* 基于Sprague-Grundy理论的组合博弈分析
*/
public class ChompSolver {
// 记忆化Grundy数:状态->Grundy值
private final Map<String, Integer> grundyCache;
// 记忆化最优移动
private final Map<String, Move> bestMoveCache;
public ChompSolver() {
this.grundyCache = new HashMap<>();
this.bestMoveCache = new HashMap<>();
}
/**
* 游戏状态类
* 用单调不增序列表示每列高度
*/
static class State {
final int[] heights;
final int rows; // 最大行数(用于显示)
State(int[] heights, int rows) {
this.heights = heights.clone();
this.rows = rows;
// 确保单调性
for (int i = 1; i < this.heights.length; i++) {
if (this.heights[i] > this.heights[i - 1]) {
throw new IllegalArgumentException("高度必须单调不增");
}
}
}
/**
* 从棋盘尺寸创建初始状态
*/
static State createInitial(int rows, int cols) {
int[] heights = new int[cols];
Arrays.fill(heights, rows);
return new State(heights, rows);
}
/**
* 生成所有合法后继状态
* 选择第col列,将其高度减少为newHeight
*/
List<State> getSuccessors() {
List<State> successors = new ArrayList<>();
int cols = heights.length;
for (int col = 0; col < cols; col++) {
if (heights[col] == 0) continue;
// 可以尝试将该列减少到 0 ~ heights[col]-1
// 但要保持单调性:heights[col] >= heights[col+1]
int maxNewHeight = heights[col] - 1;
int minNewHeight = (col + 1 < cols) ? heights[col + 1] : 0;
for (int newHeight = minNewHeight; newHeight <= maxNewHeight; newHeight++) {
int[] newHeights = heights.clone();
newHeights[col] = newHeight;
successors.add(new State(newHeights, rows));
}
}
return successors;
}
/**
* 检查是否为终止状态(只剩毒格子)
*/
boolean isTerminal() {
return heights[0] == 0;
}
/**
* 执行移动,返回新状态
*/
State applyMove(Move move) {
int[] newHeights = heights.clone();
newHeights[move.col] = move.newHeight;
// 确保右侧列不超过当前列
for (int i = move.col + 1; i < newHeights.length; i++) {
if (newHeights[i] > newHeights[i - 1]) {
newHeights[i] = newHeights[i - 1];
}
}
return new State(newHeights, rows);
}
String key() {
return Arrays.toString(heights);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof State)) return false;
State state = (State) o;
return Arrays.equals(heights, state.heights);
}
@Override
public int hashCode() {
return Arrays.hashCode(heights);
}
/**
* 打印棋盘状态
*/
void printBoard() {
System.out.println("棋盘状态 (" + rows + "x" + heights.length + "):");
for (int r = rows - 1; r >= 0; r--) {
for (int c = 0; c < heights.length; c++) {
if (r == rows - 1 && c == 0) {
System.out.print("☠ "); // 毒格子
} else if (r < heights[c]) {
System.out.print("■ "); // 巧克力
} else {
System.out.print("· "); // 已吃掉
}
}
System.out.println();
}
System.out.println("高度: " + Arrays.toString(heights));
}
}
/**
* 移动类
*/
static class Move {
final int col; // 选择的列
final int newHeight; // 新高度
Move(int col, int newHeight) {
this.col = col;
this.newHeight = newHeight;
}
@Override
public String toString() {
return String.format("将第%d列高度改为%d", col, newHeight);
}
}
/**
* 计算状态的Sprague-Grundy数
* 使用记忆化搜索
* 时间复杂度: O(状态数 × 合法移动数)
*/
public int computeGrundy(State state) {
String key = state.key();
if (grundyCache.containsKey(key)) {
return grundyCache.get(key);
}
if (state.isTerminal()) {
grundyCache.put(key, 0);
return 0;
}
// 计算所有后继状态的Grundy数
Set<Integer> reachable = new HashSet<>();
for (State succ : state.getSuccessors()) {
reachable.add(computeGrundy(succ));
}
// mex = 最小排斥值
int grundy = 0;
while (reachable.contains(grundy)) {
grundy++;
}
grundyCache.put(key, grundy);
return grundy;
}
/**
* 查找最优移动(移动到Grundy数为0的状态)
*/
public Move findBestMove(State state) {
String key = state.key();
if (bestMoveCache.containsKey(key)) {
return bestMoveCache.get(key);
}
int currentGrundy = computeGrundy(state);
if (currentGrundy == 0) {
// 当前是P-position(必败态),没有必胜移动
// 随机选择一个移动
List<State> succs = state.getSuccessors();
if (succs.isEmpty()) return null;
State randomSucc = succs.get(0);
// 重建移动
Move move = reconstructMove(state, randomSucc);
bestMoveCache.put(key, move);
return move;
}
// 寻找使后继状态Grundy数为0的移动
for (State succ : state.getSuccessors()) {
if (computeGrundy(succ) == 0) {
Move move = reconstructMove(state, succ);
bestMoveCache.put(key, move);
return move;
}
}
return null;
}
/**
* 从前后状态重建移动
*/
private Move reconstructMove(State from, State to) {
for (int i = 0; i < from.heights.length; i++) {
if (from.heights[i] != to.heights[i]) {
return new Move(i, to.heights[i]);
}
}
return null;
}
/**
* 判断是否为P-position(前一个玩家获胜,即当前玩家必败)
*/
public boolean isPPosition(State state) {
return computeGrundy(state) == 0;
}
/**
* 判断是否为N-position(下一个玩家获胜,即当前玩家必胜)
*/
public boolean isNPosition(State state) {
return computeGrundy(state) != 0;
}
/**
* 运行一局完整游戏(AI vs AI 或 AI vs 人类)
*/
public void playGame(int rows, int cols, boolean aiVsAi) {
State state = State.createInitial(rows, cols);
Scanner scanner = aiVsAi ? null : new Scanner(System.in);
int player = 1;
System.out.println("=== Chomp 游戏开始 ===");
System.out.println("规则:选择一块巧克力,吃掉该格子及其右下方所有格子");
System.out.println("被迫吃掉毒格子(☠)的玩家输!\n");
while (!state.isTerminal()) {
state.printBoard();
System.out.println("\n玩家 " + player + " 的回合");
System.out.println("Grundy数: " + computeGrundy(state));
System.out.println("当前状态: " + (isPPosition(state) ? "P-position(必败)" : "N-position(必胜)"));
Move move;
if (aiVsAi || player == 1) {
move = findBestMove(state);
if (move == null) {
// 没有必胜移动,随便走一步
List<State> succs = state.getSuccessors();
move = reconstructMove(state, succs.get(0));
}
System.out.println("AI选择: " + move);
} else {
System.out.print("输入列号和高度(如: 1 2): ");
int col = scanner.nextInt();
int h = scanner.nextInt();
move = new Move(col, h);
}
state = state.applyMove(move);
player = 3 - player; // 切换玩家 (1->2, 2->1)
}
state.printBoard();
System.out.println("\n游戏结束!玩家 " + player + " 被迫吃掉毒格子,输了!");
System.out.println("玩家 " + (3 - player) + " 获胜!");
}
/**
* 预计算并输出所有小棋盘的Grundy数
*/
public void precomputeSmallBoards(int maxRows, int maxCols) {
System.out.println("=== 预计算小棋盘Grundy数 ===");
for (int r = 1; r <= maxRows; r++) {
for (int c = 1; c <= maxCols; c++) {
State state = State.createInitial(r, c);
int g = computeGrundy(state);
String type = (g == 0) ? "P" : "N";
System.out.printf("%dx%d: Grundy=%d (%s-position)%n", r, c, g, type);
}
}
}
/**
* 分析特定棋盘的获胜策略
*/
public void analyzeBoard(int rows, int cols) {
State state = State.createInitial(rows, cols);
System.out.println("\n=== " + rows + "x" + cols + " 棋盘分析 ===");
state.printBoard();
int g = computeGrundy(state);
System.out.println("Grundy数: " + g);
System.out.println("先手: " + (g != 0 ? "必胜" : "必败"));
if (g != 0) {
Move best = findBestMove(state);
System.out.println("必胜首步: " + best);
// 展示走法后的状态
State after = state.applyMove(best);
System.out.println("\n走法后状态:");
after.printBoard();
System.out.println("对手面对Grundy=0的必败态");
}
}
public static void main(String[] args) {
ChompSolver solver = new ChompSolver();
// 预计算小棋盘
solver.precomputeSmallBoards(4, 4);
// 分析常见棋盘
solver.analyzeBoard(2, 3);
solver.analyzeBoard(3, 3);
solver.analyzeBoard(3, 5);
// 运行AI对战
System.out.println("\n=== AI 自我对弈 (3x4) ===");
solver.playGame(3, 4, true);
System.out.println("\n缓存状态数: " + solver.grundyCache.size());
}
}
算法解析
Sprague-Grundy 定理
对于无偏组合游戏(双方可选移动相同),每个状态等价于一个 Nim 堆,其大小由 Grundy 数定义:
– G(终止态) = 0
– G(状态) = mex{ G(所有后继状态) }
状态为 P-position(Previous player wins,前一个玩家获胜)当且仅当 G = 0。
状态为 N-position(Next player wins,下一个玩家获胜)当且仅当 G > 0。
Chomp 的特殊性
Chomp 满足策略窃取论证:先手必胜。但对于非 2×n 的棋盘,没有已知的多项式时间必胜策略。小棋盘可以通过穷举计算,大棋盘目前仍是开放问题。
记忆化的威力
由于 Chomp 状态空间是组合数的,3×5 棋盘就有数百个可达状态。记忆化将指数时间降为线性(相对于状态数)。
复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 状态数 | O(C(m+n, n)) | 单调序列的组合数 |
| 单次 Grundy 计算 | O(S × M) | S=状态数, M=每状态移动数 |
| 3×5 棋盘 | ~200 状态 | 毫秒级 |
| 4×6 棋盘 | ~3000 状态 | 秒级 |
| 5×7 棋盘 | ~50000 状态 | 分钟级 |
扩展思考
- 偏序集游戏:Chomp 是偏序集游戏的特例,任何有限偏序集都可以定义类似游戏。
- 三维 Chomp:m×n×p 的立方体 Chomp,状态空间爆炸更快。
- 近似策略:对大棋盘,使用蒙特卡洛树搜索(MCTS)寻找近似最优解。
总结
Chomp 是组合博弈论的入门宝石:规则极简却蕴含深刻的数学结构。Sprague-Grundy 理论为分析这类游戏提供了通用框架,而策略窃取论证则展示了存在性证明的优雅。掌握这些工具,可以分析绝大多数无偏组合游戏。