金字塔纸牌(Pyramid Solitaire)是一款经典单人纸牌游戏,玩家需要将所有28张牌从金字塔形布局中移除。规则是:每次只能移除两张点数之和为13的牌(K可单独移除,A=1,J=11,Q=12),且只有未被上层牌覆盖的牌才能被选中。本文用Java实现该游戏,核心讲解DFS深度优先搜索与状态空间剪枝策略,让AI自动寻找最优解。
一、游戏规则与状态建模
1.1 布局结构
金字塔共7层,第1层1张牌,第7层7张牌,总计28张。牌面呈倒金字塔排列:
[0,0]
[1,0] [1,1]
[2,0] [2,1] [2,2]
...
每张牌 (r, c) 被覆盖的条件是:存在上层牌 (r-1, c-1) 或 (r-1, c) 仍未被移除。
1.2 移除规则
- K(点数13)可单独移除
- 两张可见牌的点数之和为13时可配对移除(A=1, 2=2, …, J=11, Q=12)
- 剩余的24张牌构成抽牌堆(Stock),可以一张一张翻开进入废牌堆(Waste),与金字塔中的可见牌配对
1.3 状态定义
游戏状态由以下要素唯一确定:
– pyramid[7][7]:金字塔中每张牌是否已被移除(布尔数组,空位用false表示)
– stockIndex:抽牌堆当前翻到的位置
– wasteTop:废牌堆顶部的牌(若存在)
状态总数上限为 2^28 × 24,但通过剪枝实际搜索空间远小于此。
二、核心算法:DFS回溯与剪枝
2.1 DFS搜索框架
function dfs(state):
if all cards removed: return true (找到解)
moves = generateAllValidMoves(state)
if moves is empty: return false (死路)
for move in moves:
apply move to state
if dfs(newState): return true
undo move (回溯)
return false
2.2 剪枝策略
- 死牌检测:如果某张牌已可见,但没有任何可见牌的点数与之互补(即找不到配对使其和为13),且它又不是K,则该牌成为”死牌”。若死牌无法通过抽牌堆解救,则当前分支可剪枝。
- 记忆化搜索(Memoization):将访问过的状态存入HashSet,避免重复搜索相同状态。
- K优先移除:K不需要配对即可移除,优先移除K可以减少状态分支。
- 可见牌优先:优先使用金字塔中已可见的牌进行配对,减少抽牌堆的依赖。
2.3 状态哈希
为了高效判重,将当前状态编码为字符串或长整型:
– 金字塔的28个位置用28位二进制表示(1=已移除)
– stockIndex用5位表示
– wasteTop用4位表示(0~13,0表示无)
三、Java完整实现
import java.util.*;
/**
* 金字塔纸牌 — DFS回溯与状态空间剪枝
*/
public class PyramidSolitaire {
// 牌的点数:A=1, 2-10=本身, J=11, Q=12, K=13
private int[][] pyramid; // 7x7金字塔,0表示空位/已移除
private int[] stock; // 抽牌堆24张牌
private boolean[][] removed; // 标记金字塔中已移除的牌
private int stockIndex; // 当前抽到第几张
private int wasteTop; // 废牌堆顶,0表示无
// 用于记忆化搜索
private Set<Long> visited = new HashSet<>();
private List<String> solution = new ArrayList<>();
public PyramidSolitaire(int[][] pyramid, int[] stock) {
this.pyramid = new int[7][7];
this.removed = new boolean[7][7];
for (int r = 0; r < 7; r++) {
System.arraycopy(pyramid[r], 0, this.pyramid[r], 0, 7);
}
this.stock = stock.clone();
this.stockIndex = 0;
this.wasteTop = 0;
}
/**
* 检查牌(r,c)是否可见(上层两张牌都已被移除)
*/
private boolean isExposed(int r, int c) {
if (removed[r][c]) return false;
// 顶层牌总是可见的
if (r == 0) return true;
// 检查上层左右两个支撑点
boolean leftCovered = (c > 0 && !removed[r-1][c-1] && pyramid[r-1][c-1] != 0);
boolean rightCovered = (c < r && !removed[r-1][c] && pyramid[r-1][c] != 0);
// 实际上在金字塔中,每个位置的支撑是下层的两个父节点
// 修正:检查(r,c)是否被(r+1,c)和(r+1,c+1)覆盖
// 即当前牌是否被下层的牌压着
if (r < 6) {
boolean belowLeft = !removed[r+1][c] && pyramid[r+1][c] != 0;
boolean belowRight = !removed[r+1][c+1] && pyramid[r+1][c+1] != 0;
return !belowLeft && !belowRight;
}
return true; // 最底层
}
/**
* 获取所有可见牌的位置
*/
private List<int[]> getExposedCards() {
List<int[]> exposed = new ArrayList<>();
for (int r = 0; r < 7; r++) {
for (int c = 0; c <= r; c++) {
if (!removed[r][c] && pyramid[r][c] != 0 && isExposed(r, c)) {
exposed.add(new int[]{r, c, pyramid[r][c]});
}
}
}
return exposed;
}
/**
* 生成所有合法移动
*/
private List<Move> generateMoves() {
List<Move> moves = new ArrayList<>();
List<int[]> exposed = getExposedCards();
// 1. K可以单独移除
for (int[] card : exposed) {
if (card[2] == 13) {
moves.add(new Move("K", card[0], card[1], -1, -1));
}
}
// 2. 两张可见牌配对
for (int i = 0; i < exposed.size(); i++) {
for (int j = i + 1; j < exposed.size(); j++) {
int[] a = exposed.get(i);
int[] b = exposed.get(j);
if (a[2] + b[2] == 13) {
moves.add(new Move("PAIR", a[0], a[1], b[0], b[1]));
}
}
}
// 3. 可见牌与废牌堆顶配对
if (wasteTop > 0) {
for (int[] card : exposed) {
if (card[2] + wasteTop == 13) {
moves.add(new Move("WASTE", card[0], card[1], -1, -1));
}
}
}
// 4. 抽牌(翻开下一张stock到waste)
if (stockIndex < stock.length) {
moves.add(new Move("DRAW", -1, -1, -1, -1));
}
// 5. K在废牌堆顶可直接移除
if (wasteTop == 13) {
moves.add(new Move("WASTE_K", -1, -1, -1, -1));
}
return moves;
}
/**
* 执行DFS回溯搜索
*/
public boolean solve() {
// 检查是否全部移除
if (allRemoved()) {
return true;
}
// 状态哈希判重
long hash = getStateHash();
if (visited.contains(hash)) return false;
visited.add(hash);
List<Move> moves = generateMoves();
// 剪枝:按优先级排序(K优先、配对优先、抽牌最后)
moves.sort((a, b) -> {
int pa = priority(a);
int pb = priority(b);
return Integer.compare(pb, pa); // 高优先级在前
});
for (Move move : moves) {
solution.add(move.toString());
applyMove(move);
if (solve()) return true;
// 回溯
undoMove(move);
solution.remove(solution.size() - 1);
}
return false;
}
private int priority(Move m) {
return switch (m.type) {
case "K" -> 100;
case "WASTE_K" -> 95;
case "PAIR" -> 80;
case "WASTE" -> 60;
case "DRAW" -> 10;
default -> 0;
};
}
private void applyMove(Move m) {
switch (m.type) {
case "K" -> removed[m.r1][m.c1] = true;
case "PAIR" -> {
removed[m.r1][m.c1] = true;
removed[m.r2][m.c2] = true;
}
case "WASTE" -> {
removed[m.r1][m.c1] = true;
wasteTop = 0;
}
case "WASTE_K" -> wasteTop = 0;
case "DRAW" -> wasteTop = stock[stockIndex++];
}
}
private void undoMove(Move m) {
switch (m.type) {
case "K" -> removed[m.r1][m.c1] = false;
case "PAIR" -> {
removed[m.r1][m.c1] = false;
removed[m.r2][m.c2] = false;
}
case "WASTE" -> {
removed[m.r1][m.c1] = false;
// wasteTop恢复需要额外记录,简化处理:这里不恢复wasteTop
// 实际应用中应使用状态栈保存完整状态
}
case "WASTE_K" -> {} // 同上
case "DRAW" -> {
stockIndex--;
wasteTop = stockIndex > 0 ? stock[stockIndex - 1] : 0;
}
}
}
private boolean allRemoved() {
for (int r = 0; r < 7; r++) {
for (int c = 0; c <= r; c++) {
if (pyramid[r][c] != 0 && !removed[r][c]) return false;
}
}
return true;
}
private long getStateHash() {
long hash = 0;
int bit = 0;
for (int r = 0; r < 7; r++) {
for (int c = 0; c <= r; c++) {
if (removed[r][c]) hash |= (1L << bit);
bit++;
}
}
hash |= ((long) stockIndex << 28);
hash |= ((long) wasteTop << 33);
return hash;
}
public List<String> getSolution() {
return solution;
}
// ====== 移动类 ======
static class Move {
String type;
int r1, c1, r2, c2;
Move(String t, int r1, int c1, int r2, int c2) {
this.type = t; this.r1 = r1; this.c1 = c1; this.r2 = r2; this.c2 = c2;
}
public String toString() {
return switch (type) {
case "K" -> "Remove K at (" + r1 + "," + c1 + ")";
case "PAIR" -> "Pair (" + r1 + "," + c1 + ") and (" + r2 + "," + c2 + ")";
case "WASTE" -> "Pair waste with (" + r1 + "," + c1 + ")";
case "WASTE_K" -> "Remove K from waste";
case "DRAW" -> "Draw from stock";
default -> type;
};
}
}
// ====== 主程序:生成随机牌局并求解 ======
public static void main(String[] args) {
// 创建一副52张牌(仅用点数1-13,每种4张)
List<Integer> deck = new ArrayList<>();
for (int rank = 1; rank <= 13; rank++) {
for (int suit = 0; suit < 4; suit++) {
deck.add(rank);
}
}
Collections.shuffle(deck);
// 前28张放入金字塔
int[][] pyramid = new int[7][7];
int idx = 0;
for (int r = 0; r < 7; r++) {
for (int c = 0; c <= r; c++) {
pyramid[r][c] = deck.get(idx++);
}
}
// 后24张为stock
int[] stock = new int[24];
for (int i = 0; i < 24; i++) {
stock[i] = deck.get(idx++);
}
PyramidSolitaire game = new PyramidSolitaire(pyramid, stock);
System.out.println("Solving...");
long start = System.currentTimeMillis();
boolean solved = game.solve();
long end = System.currentTimeMillis();
if (solved) {
System.out.println("Solution found in " + (end - start) + "ms:");
for (String step : game.getSolution()) {
System.out.println(" " + step);
}
} else {
System.out.println("No solution found (" + (end - start) + "ms)");
}
}
}
四、关键优化:完整的状态栈回溯
上述代码中,undoMove 对 WASTE 和 WASTE_K 的恢复不够完善。生产级实现应使用状态快照模式:
class StateSnapshot {
boolean[][] removed;
int stockIndex;
int wasteTop;
// 构造函数深拷贝当前状态
}
// 在applyMove前保存快照
StateSnapshot snap = new StateSnapshot(removed, stockIndex, wasteTop);
applyMove(move);
if (solve()) return true;
restore(snap); // 从快照恢复
这种设计将状态恢复与移动逻辑解耦,大幅降低bug风险。
五、复杂度分析
- 状态空间:金字塔28张牌,每张两种状态,共
2^28 ≈ 2.68亿种;加上stockIndex(24种)和wasteTop(14种),理论上限约9×10^10。但通过可见性约束和K优先移除,实际可达状态远少于此。 - 时间复杂度:
O(分支因子^深度),最坏情况指数级。记忆化剪枝后,平均可在毫秒级求解。 - 空间复杂度:
O(访问状态数),用于记忆化集合。
六、扩展思考
- 启发式搜索:引入A*算法,设计评估函数(如”可见牌数量 + 可配对数量”),可更快找到最短解路径。
- 蒙特卡洛模拟:对于超大规模变体,可用MCTS模拟大量随机对局,选择胜率最高的分支。
- 不可解牌局检测:通过DFS搜索完整状态空间,可判定一局牌是否”可解”,统计可解率约为30%~40%。
金字塔纸牌将组合搜索与约束满足完美结合,是DFS回溯算法的经典教学案例。