七格卡槽里藏着一个 NP-hard 问题
羊了个羊的规则极简:牌堆里散落着若干层卡片,同色三张凑齐即消除;点击一张卡进入底部卡槽,卡槽最多放 7 张,放满且无法消除就游戏结束。
真正难的是那个只有 7 格的卡槽。它把问题从”找三张同色”变成了一个强约束的资源调度问题:每点一张卡,就永久占用一格,直到凑齐三张才释放。卡槽容量 C = 7 是硬约束,而”下一张该点谁”决定了整局的生死。
这个问题的判定版本可以规约到三维匹配(3-Dimensional Matching)的一个变体:给定三层卡片、每张卡有颜色和层间遮挡关系,问是否存在点击序列使卡槽永不超过 7 且全部消除。这是 NP-complete 的。
所以工程上必须放弃”全局最优”幻想,转而做两件事:
- 可解性的快速必要条件筛查(先排除必死局面,避免把算力浪费在不可能赢的局面上);
- 带前瞻的 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;
}
}
关键的三个工程细节:
- 撤销必须精确:
slot的加减要看是否触发消除,board.undo只看被解锁的那几张,不要整盘重建; - 分支截断(beam):每层只保留启发值最好的 8 个候选,把分支因子从 15 压到 8;
- 提前淘汰:
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 规划、以及任何”小缓冲 + 大堆叠”的资源调度场景。