《糖果传奇》里最让人上瘾的瞬间,是随便滑一下突然连续炸了七八波。对玩家是爽点,对引擎就是一道很干净的组合优化题:给定一个 8×8 的糖果盘面,哪一次交换能带来最大收益?
收益的定义要足够贴近真实游戏才有意义:直接消除数 + 连锁波次的加权 + 特殊糖果生成的额外价值。这篇文章用 Java 实现一个完整的 Candy Crush 决策引擎,把”找匹配””触发连锁””评估交换”三件事讲透。
一、规则的形式化
棋盘是一个 R × C 的网格,每格一种糖果类型 0..K-1。一次合法交换必须满足:
- 交换两格相邻(上下或左右)且类型不同;
- 交换之后盘面上至少存在一处三连及以上的匹配(横向或纵向连续 ≥3 个同类型)。
交换之后进入级联(cascade)阶段:
- 找出所有长度 ≥3 的同行/同列连续段,标记为待消除;
- 清除它们,上方糖果下落填补空洞;
- 空出来的顶部格子生成新糖果;
- 回到第 1 步,直到没有匹配为止。
最终收益:
score = Σ over waves ( clearedCount[wave] × waveMultiplier[wave] )
经典计分是每波递增(第 n 波按 n 倍或 2^n 计),这里用 waveMultiplier[wave] = wave + 1(第一波 1 倍、第二波 2 倍……),既符合”连锁越久越爽”的设计,又便于分析。
二、为什么不能”只试 4 个方向”
一个 8×8 棋盘有 8 × 7 × 2 = 112 对相邻位置。看起来暴力枚举全部 112 次交换、每次完整跑完级联,代价也不高——112 × 级联轮数。
问题的关键不在次数,而在每次模拟不能污染原盘。三种实现路径:
| 方案 | 做法 | 问题 |
|---|---|---|
| 原地改 + 回滚 | 交换两格、跑级联、换回来 | 级联会改乱盘面,回滚代价极高 |
| 每次深拷贝 | clone() 后模拟 |
112 次 × 64 格 = 7168 次赋值,可接受但产生垃圾 |
| 一维数组 + 显式拷贝 | System.arraycopy 到复用缓冲 |
零分配,最优 |
这里选第三种:把棋盘摊平成 int[],用两个可复用缓冲区 board 和 scratch,每次评估只做一次 arraycopy。GC 压力归零,也让”评估全部候选交换”能在一帧内完成。
三、匹配检测:一次扫描同时找横竖
朴素写法是分别扫行和扫列、各跑一遍长度计数。更快也更简洁的做法是同时扫描,用一个 mark[] 数组累积”待消除”标记:
// 横向:固定行,扫列
for (int r = 0; r < R; r++) {
int c = 0;
while (c < C) {
int v = b[idx(r, c)];
int end = c + 1;
while (end < C && b[idx(r, end)] == v) end++;
if (end - c >= 3) {
for (int k = c; k < end; k++) mark[idx(r, k)] = 1;
}
c = end;
}
}
mark 用 byte[] 而不是 boolean[],并用一个版本号技巧避免每轮清零:mark[i] == version 表示已标记。级联有几十轮时,这个优化能省掉大量 Arrays.fill。
关键细节:横向和纵向都要独立检测。 先做横向再做纵向时,纵向检测必须基于消除前的盘面,否则横向刚标记的格子类型”变了”,会漏掉十字交叉的匹配。
四、下落与填充:列内压缩
消除后,每一列都变成”有洞的数组”。下落本质是列内稳定压缩:把所有非空元素按原顺序挤到列底,剩下的顶部位置填新糖果。
for (int c = 0; c < C; c++) {
int write = R - 1; // 从底部开始写
for (int r = R - 1; r >= 0; r--) {
if (mark[idx(r, c)] == 0 && b[idx(r, c)] >= 0) {
b[idx(write, c)] = b[idx(r, c)];
write--;
}
}
while (write >= 0) { // 顶部补新糖果
b[idx(write, c)] = rng.nextInt(K);
write--;
}
}
用 write 指针做原地压缩,整个过程是 O(R) 时间、O(1) 额外空间。这个”双指针原地压缩”是数组类题的通用套路,和”移除元素后保持相对顺序”是同一个模式。
五、完整实现
import java.util.*;
/** Candy Crush 决策引擎:枚举所有合法交换,模拟完整级联并评分 */
public class Main {
static final int R = 8, C = 8;
// ---------------------- 棋盘表示 ----------------------
private final int[] board = new int[R * C]; // 摊平一维,-1 表示空
private final int[] scratch = new int[R * C]; // 评估用缓冲,复用避免分配
private final byte[] mark = new byte[R * C]; // 待消除标记
private int markVersion = 0; // 惰性清零的版本号
private final int kinds;
private final Random rng;
public Main(int kinds, long seed) {
this.kinds = kinds;
this.rng = new Random(seed);
}
private int idx(int r, int c) { return r * C + c; }
// ---------------------- 初始化:生成无匹配盘面 ----------------------
/** 初始化时就要避免开局自带匹配,否则玩家一进来就白送分数 */
public void initBoard() {
for (int r = 0; r < R; r++) {
for (int c = 0; c < C; c++) {
int v;
int guard = 0;
do {
v = rng.nextInt(kinds);
} while (guard++ < 100 && createsMatchOnPlace(r, c, v));
board[idx(r, c)] = v;
}
}
}
/** 在 (r,c) 放置 v 时,是否会与已填好的左侧两格或上方两格凑成三连 */
private boolean createsMatchOnPlace(int r, int c, int v) {
if (c >= 2 && board[idx(r, c - 1)] == v && board[idx(r, c - 2)] == v) return true;
if (r >= 2 && board[idx(r - 1, c)] == v && board[idx(r - 2, c)] == v) return true;
return false;
}
// ---------------------- 匹配检测 ----------------------
/**
* 在 b 上标记所有长度>=3 的同行/同列连续段。
* 返回被标记的格子数;0 表示盘面稳定。
*/
private int detect(int[] b) {
markVersion++;
int cleared = 0;
// 横向扫描
for (int r = 0; r < R; r++) {
int c = 0;
while (c < C) {
int v = b[idx(r, c)];
if (v < 0) { c++; continue; }
int end = c + 1;
while (end < C && b[idx(r, end)] == v) end++;
if (end - c >= 3) {
for (int k = c; k < end; k++) {
int p = idx(r, k);
if (mark[p] != (byte) markVersion) {
mark[p] = (byte) markVersion;
cleared++;
}
}
}
c = end;
}
}
// 纵向扫描(必须在清除之前,基于同一份 b)
for (int c = 0; c < C; c++) {
int r = 0;
while (r < R) {
int v = b[idx(r, c)];
if (v < 0) { r++; continue; }
int end = r + 1;
while (end < R && b[idx(end, c)] == v) end++;
if (end - r >= 3) {
for (int k = r; k < end; k++) {
int p = idx(k, c);
if (mark[p] != (byte) markVersion) {
mark[p] = (byte) markVersion;
cleared++;
}
}
}
r = end;
}
}
return cleared;
}
// ---------------------- 下落与填充 ----------------------
private void collapse(int[] b) {
for (int c = 0; c < C; c++) {
int write = R - 1;
for (int r = R - 1; r >= 0; r--) {
int p = idx(r, c);
if (mark[p] != (byte) markVersion) {
b[idx(write, c)] = b[p];
write--;
}
}
while (write >= 0) {
b[idx(write, c)] = rng.nextInt(kinds);
write--;
}
}
}
// ---------------------- 级联模拟 ----------------------
/** 级联结果:加权总分 + 连锁波数 */
private static final class ResolveResult {
final int score, waves;
ResolveResult(int score, int waves) { this.score = score; this.waves = waves; }
}
/**
* 在给定的盘面 b 上完整模拟坠落到稳定,返回加权总分与连锁波数。
* 注意:b 会被修改,调用者需自行保证传入的是副本。
*/
private ResolveResult resolve(int[] b) {
int total = 0, wave = 0;
while (true) {
int cleared = detect(b);
if (cleared == 0) break;
wave++;
total += cleared * wave; // 波次递增权重
collapse(b);
}
return new ResolveResult(total, wave);
}
// ---------------------- 交换评估 ----------------------
public static final class Move {
final int r1, c1, r2, c2, score, waves;
Move(int r1, int c1, int r2, int c2, int score, int waves) {
this.r1 = r1; this.c1 = c1; this.r2 = r2; this.c2 = c2;
this.score = score; this.waves = waves;
}
@Override public String toString() {
return String.format("(%d,%d)<->(%d,%d) 得分=%d 连锁=%d波",
r1, c1, r2, c2, score, waves);
}
}
/** 枚举全部 112 个相邻交换,返回按得分降序排列的候选 */
public List<Move> rankMoves() {
List<Move> out = new ArrayList<>();
for (int r = 0; r < R; r++) {
for (int c = 0; c < C; c++) {
// 只向右、向下枚举,天然不重复(每对相邻格只出现一次)
if (c + 1 < C) out.add(evalSwap(r, c, r, c + 1));
if (r + 1 < R) out.add(evalSwap(r, c, r + 1, c));
}
}
out.removeIf(m -> m == null);
out.sort((a, b) -> Integer.compare(b.score, a.score));
return out;
}
private Move evalSwap(int r1, int c1, int r2, int c2) {
int p1 = idx(r1, c1), p2 = idx(r2, c2);
if (board[p1] == board[p2]) return null; // 同类型交换无意义
// 拷到评估缓冲,不污染原盘
System.arraycopy(board, 0, scratch, 0, board.length);
int t = scratch[p1]; scratch[p1] = scratch[p2]; scratch[p2] = t;
ResolveResult rr = resolve(scratch);
if (rr.waves == 0) return null; // 交换后毫无匹配,不是合法走法
return new Move(r1, c1, r2, c2, rr.score, rr.waves);
}
// ---------------------- 主流程 ----------------------
public static void main(String[] args) {
Main game = new Main(5, 42L);
game.initBoard();
System.out.println("初始盘面:");
printBoard(game.board);
long t0 = System.nanoTime();
List<Move> ranked = game.rankMoves();
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.println("\n可产生消除的交换数:" + ranked.size() + ",评估耗时 " + ms + "ms");
for (int i = 0; i < Math.min(5, ranked.size()); i++) {
System.out.println(" #" + (i + 1) + " " + ranked.get(i));
}
}
static void printBoard(int[] b) {
for (int r = 0; r < R; r++) {
StringBuilder sb = new StringBuilder();
for (int c = 0; c < C; c++) {
sb.append((char) ('A' + b[r * C + c])).append(' ');
}
System.out.println(sb);
}
}
}
resolve 为什么必须同时返回 (score, waves),而不是只返回分数?因为波数本身是走法质量的关键信号:同样是 12 分,一次清完和连炸四波的价值完全不同,后者往往意味着盘面被彻底打开,后续空间更大。而分数无法反推波数——score = Σ(cleared_k × k) 是多个未知数的和,方程多解,从分数倒推波数根本不成立。让函数直接把两个量都带出来,是唯一可靠的做法。
evalSwap 里也刻意省掉了”先 detect 预检查、再进 resolve“的写法:resolve 首轮的 detect 就是同一件事,waves == 0 天然等价于”这个交换不产生任何匹配”。少一次全盘扫描,在 112 个候选 × 每次盘面变化都重算的场景下是实打实的收益。
六、评估函数还能怎么打磨
上面的评分只看”消除了多少”,真实引擎会把特殊糖果的价值算进去。常见的加权项:
- 四连生成条纹糖(清除一整行/列):等效收益 ≈
C或R; - L/T 形五连生成包装糖(炸 3×3):等效收益 ≈ 9 减重叠部分;
- 五连一线生成彩虹糖(清除全部同色):等效收益 ≈ 该颜色在盘面上的数量;
- 连锁位置:靠底部的消除更容易引发二次坍塌,可给底部波次额外加权。
把它写成可插拔的 ScorePolicy 接口,就能在不改搜索逻辑的前提下替换评估口径:
public interface ScorePolicy {
int scoreOf(int cleared, int wave, int[] boardAfter);
}
这是游戏 AI 的一条通用原则:搜索负责”枚举”,评估负责”判断”,两者永远分开。评估函数可以随便调参、随便替换,搜索骨架一个字都不用动。
七、复杂度
- 候选交换数:
2RC - R - C ≈ 112(8×8)。 - 单次级联:最坏
O(RC × waves),waves实测很少超过 5。 - 总体:
O(RC × moves × waves) = O(64 × 112 × 5) ≈ 3.6 万次基本操作。单帧内跑完毫无压力,这也是为什么三消游戏的”最佳走法提示”能做得那么即时。
八、更进一步:从”这一手最好”到”未来三步最好”
单步贪心有一个已知缺陷:它看不见”这一手消掉后,下一手能不能凑出五连”。改成两层搜索就能明显改善:
for each move m1:
apply(m1) -> board1, score1
best2 = max over m2 of score(m2 on board1)
value(m1) = score1 + 0.8 * best2
衰减系数 0.8 体现”未来收益要打折”。这就是一个深度为 2 的 Expectimax 简化版——之所以是”期望”而不是”最大化”,是因为新糖果的生成是随机的,严格的做法应当对填充结果求期望。实践中通常用”多次随机 rollout 取均值”来近似,这一步一加,你的三消 AI 就从”贪心”升级成了”有远见”。