每日算法 — 使用java实现羊了个羊:栈结构匹配与DFS回溯消除策略

引言

羊了个羊是一款现象级的三消类益智游戏,核心玩法是将三层堆叠的图案牌逐张移入槽位,当槽位中累积三张相同图案时即可消除。游戏看似简单,实则蕴含深厚的组合数学与搜索算法思想。本文将用Java实现该游戏的核心引擎,重点讲解基于栈结构的消除匹配与DFS回溯求解算法,分析如何判定一局游戏是否可解,以及如何用搜索算法寻找最优消除序列。

问题建模

游戏规则抽象

  1. 牌堆结构:N张牌分为M种图案,每种图案有3张,堆叠成多层结构
  2. 可见性:只有不被其他牌覆盖的牌才可点击
  3. 槽位机制:底部有7个槽位,点击的牌进入槽位
  4. 消除规则:槽位中某图案累积达到3张时自动消除
  5. 胜负判定:槽位满7张且无法消除时失败;所有牌消除时胜利

数据结构定义

  • 每张牌具有:唯一ID、图案类型、所在层数、覆盖关系
  • 覆盖关系:如果牌A的矩形区域与牌B重叠且A在B上层,则A覆盖B
  • 可见牌:不被任何上层牌覆盖的牌

核心算法一:可见牌检测与覆盖关系

通过二维网格或矩形相交检测来确定牌的覆盖关系。

核心算法二:DFS回溯求解

判定当前局面是否有解,核心思路是:
1. 枚举所有当前可见牌的选择
2. 模拟选择后的状态变化(槽位变化、新牌露出)
3. 递归搜索,直到找到解或穷尽所有可能
4. 使用记忆化或剪枝避免重复搜索

完整Java实现

package sheep.game;

import java.util.*;

/**
 * 羊了个羊 - 栈结构匹配与DFS回溯求解引擎
 * 
 * 核心功能:
 * 1. 多层牌堆的覆盖关系建模与可见牌检测
 * 2. 槽位栈结构的匹配消除逻辑
 * 3. DFS回溯搜索判定局面可解性
 * 4. 寻找最优消除序列
 */
public class SheepGameSolver {

    /**
     * 牌的图案类型
     */
    public enum Pattern {
        CARROT, CORN, GLOVE, BRUSH, SCISSORS,
        BELL, FIRE, GRASS, MILK, WOOL
    }

    /**
     * 单张牌
     */
    public static class Card {
        public final int id;
        public final Pattern pattern;
        public final int layer;         // 所在层数,越大越靠上
        public final int x, y;          // 坐标位置
        public final int width, height; // 尺寸

        public Card(int id, Pattern pattern, int layer, int x, int y, int w, int h) {
            this.id = id;
            this.pattern = pattern;
            this.layer = layer;
            this.x = x;
            this.y = y;
            this.width = w;
            this.height = h;
        }

        /**
         * 判断两个牌是否发生矩形重叠
         */
        public boolean overlaps(Card other) {
            return this.x < other.x + other.width
                && this.x + this.width > other.x
                && this.y < other.y + other.height
                && this.y + this.height > other.y;
        }
    }

    /**
     * 游戏状态
     */
    public static class GameState {
        public final List<Card> remainingCards;  // 剩余未消除的牌
        public final List<Card> slot;            // 当前槽位中的牌(最多7张)
        public final List<Move> moves;           // 已执行的移动序列

        public GameState(List<Card> remaining, List<Card> slot, List<Move> moves) {
            this.remainingCards = new ArrayList<>(remaining);
            this.slot = new ArrayList<>(slot);
            this.moves = new ArrayList<>(moves);
        }

        public GameState copy() {
            return new GameState(remainingCards, slot, moves);
        }
    }

    /**
     * 一次移动操作
     */
    public static class Move {
        public final Card card;
        public final int slotIndex;

        public Move(Card card, int slotIndex) {
            this.card = card;
            this.slotIndex = slotIndex;
        }
    }

    /**
     * 构建覆盖关系图
     * 
     * @param cards 所有牌的列表
     * @return 覆盖映射:对于每张牌,哪些牌覆盖在它上面
     */
    public static Map<Card, Set<Card>> buildCoverGraph(List<Card> cards) {
        Map<Card, Set<Card>> coverMap = new HashMap<>();

        for (Card card : cards) {
            coverMap.put(card, new HashSet<>());
        }

        // 对于每对牌,如果发生重叠且层数更高,则上层覆盖下层
        for (int i = 0; i < cards.size(); i++) {
            for (int j = i + 1; j < cards.size(); j++) {
                Card a = cards.get(i);
                Card b = cards.get(j);
                if (a.overlaps(b)) {
                    if (a.layer > b.layer) {
                        coverMap.get(b).add(a); // a覆盖b
                    } else if (b.layer > a.layer) {
                        coverMap.get(a).add(b); // b覆盖a
                    }
                }
            }
        }

        return coverMap;
    }

    /**
     * 获取当前可见的牌(不被任何剩余牌覆盖)
     */
    public static Set<Card> getVisibleCards(List<Card> remaining,
                                             Map<Card, Set<Card>> coverGraph) {
        Set<Card> visible = new HashSet<>();
        for (Card card : remaining) {
            boolean covered = false;
            for (Card cover : coverGraph.get(card)) {
                if (remaining.contains(cover)) {
                    covered = true;
                    break;
                }
            }
            if (!covered) {
                visible.add(card);
            }
        }
        return visible;
    }

    /**
     * 将牌放入槽位,处理消除逻辑
     * 
     * @param slot  当前槽位
     * @param card  要放入的牌
     * @return 消除后的槽位状态,若槽位溢出(超过7张且无法消除)返回null
     */
    public static List<Card> placeIntoSlot(List<Card> slot, Card card) {
        List<Card> newSlot = new ArrayList<>(slot);
        newSlot.add(card);

        // 检查是否有3张相同的图案可以消除
        Map<Pattern, List<Card>> patternGroups = new HashMap<>();
        for (Card c : newSlot) {
            patternGroups.computeIfAbsent(c.pattern, k -> new ArrayList<>()).add(c);
        }

        // 消除所有达到3张的图案
        List<Card> afterElimination = new ArrayList<>();
        for (Card c : newSlot) {
            if (patternGroups.get(c.pattern).size() < 3) {
                afterElimination.add(c);
            }
        }

        // 槽位最多容纳7张未消除的牌
        if (afterElimination.size() > 7) {
            return null; // 槽位溢出,游戏失败
        }

        return afterElimination;
    }

    /**
     * 计算状态哈希,用于记忆化
     */
    private static String stateHash(List<Card> remaining, List<Card> slot) {
        StringBuilder sb = new StringBuilder();
        List<Integer> remIds = new ArrayList<>();
        for (Card c : remaining) remIds.add(c.id);
        Collections.sort(remIds);
        for (int id : remIds) sb.append(id).append(',');
        sb.append('|');
        for (Card c : slot) sb.append(c.pattern.ordinal());
        return sb.toString();
    }

    /**
     * DFS回溯搜索求解
     * 
     * @param state         当前游戏状态
     * @param coverGraph    覆盖关系图
     * @param memo          记忆化集合,记录已访问的状态
     * @return 若可解返回true,并将解路径存入state.moves
     */
    public static boolean dfsSolve(GameState state,
                                    Map<Card, Set<Card>> coverGraph,
                                    Set<String> memo) {
        // 所有牌已消除,胜利
        if (state.remainingCards.isEmpty()) {
            return true;
        }

        String hash = stateHash(state.remainingCards, state.slot);
        if (memo.contains(hash)) {
            return false; // 该状态已搜索过且不可解
        }
        memo.add(hash);

        // 获取当前可见牌
        Set<Card> visible = getVisibleCards(state.remainingCards, coverGraph);

        // 如果没有可见牌但还有剩余牌,死锁
        if (visible.isEmpty() && !state.remainingCards.isEmpty()) {
            return false;
        }

        // 按启发式策略排序可见牌:优先消除槽位中已有的图案
        List<Card> sortedVisible = new ArrayList<>(visible);
        Map<Pattern, Integer> slotPatternCount = new HashMap<>();
        for (Card c : state.slot) {
            slotPatternCount.merge(c.pattern, 1, Integer::sum);
        }
        sortedVisible.sort((a, b) -> {
            int ca = slotPatternCount.getOrDefault(a.pattern, 0);
            int cb = slotPatternCount.getOrDefault(b.pattern, 0);
            return cb - ca; // 槽位中已有更多的优先
        });

        for (Card card : sortedVisible) {
            // 模拟将牌放入槽位
            List<Card> newSlot = placeIntoSlot(state.slot, card);
            if (newSlot == null) continue; // 槽位溢出,跳过

            // 构建新状态
            List<Card> newRemaining = new ArrayList<>(state.remainingCards);
            newRemaining.remove(card);

            List<Move> newMoves = new ArrayList<>(state.moves);
            newMoves.add(new Move(card, state.slot.size()));

            GameState nextState = new GameState(newRemaining, newSlot, newMoves);

            if (dfsSolve(nextState, coverGraph, memo)) {
                // 将子状态的解复制回当前状态
                state.moves.clear();
                state.moves.addAll(nextState.moves);
                return true;
            }
        }

        return false;
    }

    /**
     * 生成一局可解的游戏
     */
    public static List<Card> generateSolvableGame(int patternCount, int layers) {
        List<Card> cards = new ArrayList<>();
        Random rand = new Random();
        int id = 0;

        Pattern[] patterns = Pattern.values();
        // 每种图案3张
        for (int p = 0; p < patternCount && p < patterns.length; p++) {
            for (int copy = 0; copy < 3; copy++) {
                int layer = rand.nextInt(layers);
                int x = rand.nextInt(400);
                int y = rand.nextInt(400);
                cards.add(new Card(id++, patterns[p], layer, x, y, 60, 60));
            }
        }

        return cards;
    }

    /**
     * 打印当前游戏状态
     */
    public static void printState(GameState state, Map<Card, Set<Card>> coverGraph) {
        Set<Card> visible = getVisibleCards(state.remainingCards, coverGraph);
        System.out.printf("剩余牌: %d | 可见牌: %d | 槽位: %d%n",
            state.remainingCards.size(), visible.size(), state.slot.size());
        System.out.print("槽位: ");
        for (Card c : state.slot) {
            System.out.print(c.pattern + " ");
        }
        System.out.println();
        System.out.print("可见: ");
        for (Card c : visible) {
            System.out.printf("[%d]%s ", c.id, c.pattern);
        }
        System.out.println("\n");
    }

    // ==================== 测试入口 ====================
    public static void main(String[] args) {
        System.out.println("===== 羊了个羊 DFS回溯求解 =====\n");

        // 生成一局小型游戏:5种图案 × 3张 = 15张牌,3层
        List<Card> cards = generateSolvableGame(5, 3);
        System.out.printf("生成牌堆: %d 张牌,%d 种图案%n\n", cards.size(), 5);

        // 构建覆盖关系
        Map<Card, Set<Card>> coverGraph = buildCoverGraph(cards);

        // 初始状态
        GameState initial = new GameState(cards, new ArrayList<>(), new ArrayList<>());
        printState(initial, coverGraph);

        // DFS求解
        Set<String> memo = new HashSet<>();
        long start = System.currentTimeMillis();
        boolean solvable = dfsSolve(initial, coverGraph, memo);
        long elapsed = System.currentTimeMillis() - start;

        System.out.printf("求解结果: %s (耗时 %dms, 搜索状态数: %d)%n\n",
            solvable ? "可解" : "不可解", elapsed, memo.size());

        if (solvable && !initial.moves.isEmpty()) {
            System.out.println("===== 最优消除序列 =====");
            GameState simState = new GameState(cards, new ArrayList<>(), new ArrayList<>());
            for (int i = 0; i < initial.moves.size(); i++) {
                Move move = initial.moves.get(i);
                System.out.printf("Step %d: 选择 [%d]%s -> ", i + 1, move.card.id, move.card.pattern);

                List<Card> newSlot = placeIntoSlot(simState.slot, move.card);
                List<Card> newRem = new ArrayList<>(simState.remainingCards);
                newRem.remove(move.card);

                boolean eliminated = newSlot.size() < simState.slot.size() + 1;
                System.out.print(eliminated ? "消除! " : "入槽  ");
                System.out.printf("(槽位: %d张)%n", newSlot.size());

                simState = new GameState(newRem, newSlot, simState.moves);
            }
            System.out.println("\n全部消除完成!");
        }
    }
}

算法原理深入

覆盖关系图的构建

覆盖关系图是有向无环图(DAG),边从上层牌指向下层牌。关键性质:
– 如果牌A覆盖牌B,则B在A被移除前不可点击
– 入度为0的节点(不被任何剩余牌覆盖)即为当前可见牌

DFS搜索的剪枝策略

  1. 槽位溢出剪枝:如果某选择导致槽位超过7张且无法消除,立即回溯
  2. 图案计数剪枝:如果某图案剩余张数不是3的倍数,且无法与槽位中已有牌组合消除,提前判定不可解
  3. 记忆化:记录已访问的 (remainingCards, slot) 状态,避免重复搜索

复杂度分析

指标 最坏情况 实际游戏 说明
状态空间 O(2^N × N!) 极大剪枝 N为牌数
可见牌枚举 O(V) V很小 V为当前可见牌数
覆盖图构建 O(N²) 一次性 N为总牌数
实际求解 毫秒级 剪枝有效 小型实例可秒解

游戏设计视角

可解性控制

羊了个羊第二关 notoriously 难的原因:
1. 层数设计:关键牌被深层牌完全覆盖,形成”死锁”
2. 图案分布:某些图案的大量牌集中在同一层,导致前期无法有效消除
3. 随机性:完全随机的发牌可能导致本质不可解的局面

改进算法:带随机重启的迭代求解

public static List<Card> generateGuaranteedSolvable(int patterns, int layers) {
    for (int attempt = 0; attempt < 1000; attempt++) {
        List<Card> cards = generateSolvableGame(patterns, layers);
        GameState state = new GameState(cards, new ArrayList<>(), new ArrayList<>());
        Map<Card, Set<Card>> graph = buildCoverGraph(cards);
        Set<String> memo = new HashSet<>();
        if (dfsSolve(state, graph, memo)) {
            return cards; // 找到可解布局
        }
    }
    throw new RuntimeException("无法生成可解布局");
}

总结

本文用Java实现了羊了个羊游戏的核心求解引擎,涵盖:
– 多层牌堆的覆盖关系建模与可见牌检测
– 基于栈的槽位消除机制
– DFS回溯搜索与多种剪枝策略
– 可解性分析与保证可解的游戏生成

该实现展示了如何将看似休闲的游戏转化为严谨的组合搜索问题。DFS回溯与状态空间剪枝的思想在自动推理、约束满足问题(CSP)、游戏AI等领域具有普适性。