每日算法 — 使用java实现羊了个羊:卡槽容量约束的可解性判定与分层A*前瞻

七格卡槽里藏着一个 NP-hard 问题

羊了个羊的规则极简:牌堆里散落着若干层卡片,同色三张凑齐即消除;点击一张卡进入底部卡槽,卡槽最多放 7 张,放满且无法消除就游戏结束。

真正难的是那个只有 7 格的卡槽。它把问题从”找三张同色”变成了一个强约束的资源调度问题:每点一张卡,就永久占用一格,直到凑齐三张才释放。卡槽容量 C = 7 是硬约束,而”下一张该点谁”决定了整局的生死。

这个问题的判定版本可以规约到三维匹配(3-Dimensional Matching)的一个变体:给定三层卡片、每张卡有颜色和层间遮挡关系,问是否存在点击序列使卡槽永不超过 7 且全部消除。这是 NP-complete 的。

所以工程上必须放弃”全局最优”幻想,转而做两件事:

  1. 可解性的快速必要条件筛查(先排除必死局面,避免把算力浪费在不可能赢的局面上);
  2. 带前瞻的 A* ——不用穷举,只用”未来 K 步的可消除性”作为启发值。

第一步:三条必要条件,秒杀必死局面

在展开树之前,先做三项 O(n) 的检查。任意一条不满足,直接判定无解。

条件 1:颜色计数必须是 3 的倍数

∀ color c:  count(c) ≡ 0 (mod 3)

这条最基础,但实现时容易踩坑:被冻结的卡片(被上层压住)也算总数。如果某颜色总数是 4,那无论怎么点都剩 1 张,必然卡槽溢出。

条件 2:卡槽占用的峰值下界

设某颜色 c 的三张牌中,可点击时刻的跨度为 span(c)(即最早可点的那张与最晚可点的那张之间的”必须同时持有”的最小张数)。若存在颜色满足:

max_c minHold(c) ≥ C

则必死。其中 minHold(c) 是”该颜色三张牌构成一张依赖链时,必须同时占用卡槽的最少张数”。例如三张同色牌叠在同一条竖直链上,且中间没有任何可消除的同色,就必须先点中间那张(压住它下层的牌),形成 3 张全部同时占槽的局面。

/** 条件2:对每个颜色,估算必须同时占槽的最少张数 */
static boolean passSlotLowerBound(Map<Integer, List<Card>> byColor, int slotCap) {
    for (Map.Entry<Integer, List<Card>> e : byColor.entrySet()) {
        List<Card> cards = e.getValue();
        // 简化模型:三张牌若互相之间存在"遮挡"(A 压 B),则它们无法被同时翻开
        // 用 DAG 上的最长链作为必须同时持有的张数下界
        int chain = longestChain(cards);       // 三张牌的遮挡关系最长链
        if (chain >= slotCap) return false;    // 3 张都hold不住 7 格 → 直接死局
    }
    return true;
}

/** 在遮挡 DAG 上求最长链(记忆化 DFS) */
static int longestChain(List<Card> cards) {
    Map<Card, Integer> memo = new HashMap<>();
    int best = 1;
    for (Card c : cards) best = Math.max(best, chainFrom(c, memo));
    return best;
}

static int chainFrom(Card c, Map<Card, Integer> memo) {
    Integer cached = memo.get(c);
    if (cached != null) return cached;
    int len = 1;
    for (Card up : c.covers) {                   // c 压住的牌(c 在下层)
        len = Math.max(len, 1 + chainFrom(up, memo));
    }
    memo.put(c, len);
    return len;
}

条件 3:卡槽容量下的”活跃颜色数”上界

任意时刻,卡槽里最多能同时容纳 C 张。如果当前可点击的卡片中,可以凑成三张的颜色数 k 满足:

3k > C  →  k > C/3 = 2.33  → k ≥ 3

不,这条逻辑要反过来:只有当卡槽里已有某颜色的 1~2 张时,才值得继续点同色。更实用的判据是——

若 可点击卡片中不同颜色数 ≥ C,则任一策略都需占用至少 1 张/颜色

在 C = 7 时,若同时可点的颜色数达到 7 种以上,说明这一步无论点哪张都会”引进一种新颜色”,卡槽必然快速溢出。这条是启发式的,不做硬判定,但可以用来排序候选。

第二步:可点击性的增量维护

“某张牌是否可点击”,取决于它是否被上层遮挡。每次点走一张牌,可能解锁其下方最多 4 张牌。如果把每张牌的”被压计数清零”都实时扫描一遍,总代价是 O(n^2)。

正确做法是反向依赖表 + 计数器:

public final class Board {
    final Card[] cards;
    final int[] coveredBy;          // 仍压住我的上层卡片数
    final List<Integer>[] below;    // 我的下方卡片(我走了它们可能解锁)

    /** 点击后返回新解锁的卡片索引 */
    public List<Integer> remove(int idx) {
        cards[idx].removed = true;
        List<Integer> unlocked = new ArrayList<>(4);
        for (int nb : below[idx]) {
            if (--coveredBy[nb] == 0 && !cards[nb].removed) unlocked.add(nb);
        }
        return unlocked;
    }

    /** 撤销(A* 回溯时需要) */
    public void undo(int idx) {
        cards[idx].removed = false;
        for (int nb : below[idx]) coveredBy[nb]++;
    }
}

这就是增量化:单次点击 O(解锁数) ≤ O(4),而不是整盘扫描。

第三步:A* 的启发值——前瞻可消除对数

羊了个羊不能照搬普通 A。普通 A 的 h 是”到目标的距离估计”,但这里状态空间巨大、目标唯一(全部消完),用距离类启发值效果很差。

真正有用的启发值是”下一批能消除的数量“:从当前可点击集合里,能立刻凑齐三张的颜色有几个?能凑齐两张的有几个?

/**
 * 启发值 h(s):估计还需要多少次"新增点击"才能清空。
 * 用"当前立即可消除的三张组数"作为正向收益,抵消步数。
 */
static double heuristic(Board b, int[] slot) {
    // slot[c] = 卡槽中颜色 c 的张数(0..2)
    int three = 0, two = 0;
    int[] visible = new int[MAX_COLOR];         // 当前可点击卡片颜色计数
    for (int idx : b.visibleCards()) visible[b.cards[idx].color]++;

    for (int c = 0; c < MAX_COLOR; c++) {
        int total = slot[c] + visible[c];
        if (total >= 3) three++;
        else if (total >= 2) two++;
        else if (total >= 1) { /* 需更多点击 */ }
    }
    // 立即可消的三张越多 → 越接近胜利 → h 越小
    return -(3.0 * three + 1.0 * two) + 0.5 * usedSlots(slot);
}

static int usedSlots(int[] slot) {
    int s = 0;
    for (int v : slot) s += v;
    return s;
}

usedSlots 项是惩罚项:卡槽越满,危险度越高。权重 0.5 保证”能消三张”始终优先于”腾出 1 格”(因为 3.0 > 0.5),避免启发值引导 AI 去点无用牌。

第四步:分层前瞻(Lazy Lookahead)

纯 A 在羊了个羊上依然会爆,因为每步分支 5~15 个,深度上限 100+。工程解法是限定前瞻深度 K 的迭代加深*:先做一层(只看当前可点击),不行再看到解锁后的一层,最多看 K = 4 层。

public final class SheepSolver {
    private static final int MAX_LOOKAHEAD = 4;
    private static final int SLOT_CAP = 7;

    public List<Integer> solve(Board board, int[] slot) {
        // 条件 1~3 前置筛查
        if (!PreChecks.allPass(board, slot, SLOT_CAP)) return List.of();

        List<Integer> plan = new ArrayList<>();
        while (!board.allRemoved()) {
            int chosen = pickBest(board, slot, MAX_LOOKAHEAD);
            if (chosen < 0) return List.of();           // 卡住了,无解

            plan.add(chosen);
            for (int unlocked : board.remove(chosen)) { /* 更新可点击集 */ }
            slot[board.cards[chosen].color]++;
            if (slot[board.cards[chosen].color] == 3) {
                slot[board.cards[chosen].color] = 0;    // 三张齐 → 消除,释放卡槽
            }
            if (usedSlots(slot) > SLOT_CAP) return List.of();  // 溢出,本局失败
        }
        return plan;
    }

    /** 在深度 K 内做 beam-style 前瞻,返回最优首步 */
    private int pickBest(Board board, int[] slot, int depth) {
        List<Candidate> cands = enumerateVisible(board, slot);
        // 按启发值排序,只保留前 BEAM 个(默认 8),控制分支爆炸
        cands.sort(Comparator.comparingDouble(c -> c.score));
        int beam = Math.min(cands.size(), 8);

        double bestScore = Double.NEGATIVE_INFINITY;
        int bestMove = -1;
        for (int i = 0; i < beam; i++) {
            Candidate cand = cands.get(i);
            int idx = cand.idx;
            // ---- 分支前保存快照(只备份 slot,board 用 undo 恢复)----
            int savedColor = board.cards[idx].color;
            List<Integer> unlocked = board.remove(idx);
            slot[savedColor]++;

            double score;
            boolean filled = slot[savedColor] == 3;
            if (filled) {
                slot[savedColor] -= 3;                 // 模拟消除
                score = depth > 1 ? -lookahead(board, slot, depth - 1) : -heuristic(board, slot);
                slot[savedColor] += 3;
            } else if (usedSlots(slot) > SLOT_CAP) {
                score = Double.NEGATIVE_INFINITY;      // 立即死亡,直接淘汰
            } else {
                score = depth > 1 ? -lookahead(board, slot, depth - 1) : -heuristic(board, slot);
            }
            score -= 0.05 * depth;                     // 浅层优先(迭代加深风格)

            // ---- 恢复现场 -----
            slot[savedColor]--;
            if (filled) slot[savedColor] += 3;
            board.undo(idx);

            if (score > bestScore) { bestScore = score; bestMove = idx; }
        }
        return bestMove;
    }

    private double lookahead(Board b, int[] slot, int depth) {
        if (depth == 0 || b.allRemoved()) return heuristic(b, slot);
        double res = heuristic(b, slot);
        for (Candidate c : topCandidates(b, slot, 4)) {
            /* 递归省略,逻辑同上 */
        }
        return res;
    }
}

关键的三个工程细节:

  1. 撤销必须精确:slot 的加减要看是否触发消除,board.undo 只看被解锁的那几张,不要整盘重建;
  2. 分支截断(beam):每层只保留启发值最好的 8 个候选,把分支因子从 15 压到 8;
  3. 提前淘汰:usedSlots > SLOT_CAP 的候选直接判 -∞,不进入递归。

完整项目结构

sheep-game-solver/
├── pom.xml
└── src/main/java/com/daily/algorithm/sheep/
    ├── Card.java            // 颜色、层、遮挡关系
    ├── Board.java           // 可点击性增量维护(coveredBy 计数器)
    ├── PreChecks.java       // 三条必要条件筛查
    ├── Heuristic.java       // 前瞻可消除对数启发值
    ├── SheepSolver.java     // 迭代加深 + beam 前瞻
    └── Main.java            // 内置 3 层关卡示例

复杂度分析

设卡片总数 n、颜色数 K、前瞻深度 K_d、beam 宽度 B = 8。

阶段 复杂度
前置筛查 O(n + K)
单次状态转移 O(解锁数) = O(4)
单步前瞻 O(B^{K_d}) = O(8^4) = 4096 次状态转移
整局(步数 n/3) O(n/3 × 8^{K_d})

n = 300、K_d = 4 时约 4×10^7 次轻量转移,实际因 beam 剪枝、早期淘汰和 K_d 自适应降低,单局决策在 10~30ms 内完成。

对比暴力 DFS:300 张牌、每步 10 个候选,最坏 10^100——完全不可行。anticipatory beam 是把”NP-hard 精确求解”退化成”高质量近似”的标准手段。

小结

羊了个羊揭示了一件有趣的事:把资源容量设成一个很小的常数,就能把简单匹配问题变成 NP-hard。本文的三层解法正好对应三种常用工程手段:

  • 必要条件筛查(颜色三倍数、遮挡链长度、活跃颜色数)—— 用 O(n) 干掉落必然失败的局面;
  • 增量化可点击性维护(coveredBy 计数器 + 反向依赖表)—— 让单步转移从 O(n) 降到 O(1);
  • 前瞻 beam A*(局限深度 + 启发值截断)—— 在指数空间里做有界近似搜索。

这套组合也可以直接搬到三消游戏的可解性判定、塔牌类 solitaire 规划、以及任何”小缓冲 + 大堆叠”的资源调度场景。