每日算法 — 使用java实现祖玛:游程编码压缩与区间DP最优消除

祖玛(Zuma)的规则每个街机玩家都熟:一条彩色球链沿轨道推进,你从炮口射出彩球插入链中,三颗及以上同色相连即消除,两侧合并后可能连锁。人类凭直觉玩,AI 却要回答一个精确问题——在有限的球序列下,射哪一球、插在哪个位置,才能让总消除数最大?

多数教程只写”链表 + 递归消除”,能跑通但不对。真正的难点在于消除顺序具有全局最优结构:先消除中间一小段,可以让两端的同色球合并,从而触发更大的连锁。这个”两端合并”的性质,正是区间动态规划(Interval DP)的教科书适用条件。本文先用游程编码(Run-Length Encoding)把链压缩,再在压缩序列上做区间 DP,把问题从”指数级枚举插入方案”降到 O(n^3)。

一、为什么必须先做游程编码

原始链里连续同色的球,其行为完全由长度决定:长度 ≥ 3 时会被立即消除;长度 1 或 2 时则像”半个三连”一样等待合并。因此把 红红红蓝蓝绿绿绿 压成 (红,3) (蓝,2) (绿,3) 之后:

  • 元素个数从 8 降到 3,状态空间大幅收缩;
  • 每个元素只需存 (颜色, 计数 mod 3 意义下的余量),因为计数 ≥ 3 的部分会被立刻消掉;
  • 后续所有分析(合并、连锁)都能用”游程相加”一句话表达。

用一个更精确的约定:压缩后每个游程的 count ∈ {1, 2}。若原始计数 ≥ 3,先消除掉 count - (count % 3 == 0 ? 3 : count % 3) 的部分——不过为了叙述清晰,下面的实现统一采用”插入并模拟消除”的循环,压缩阶段只做相邻同色合并,count 保留真实值,消除时再判断 >= 3。

package com.example.zuma;

/** 一个游程:颜色 + 连续个数 */
public class Run {
    public final int color;
    public int count;

    public Run(int color, int count) { this.color = color; this.count = count; }

    @Override public String toString() { return "(" + color + "*" + count + ")"; }
}

/** 游程编码:把彩色球链压缩成游程数组 */
public final class Rle {
    public static Run[] encode(int[] balls) {
        if (balls.length == 0) return new Run[0];
        java.util.List<Run> out = new java.util.ArrayList<>();
        int curColor = balls[0], cnt = 1;
        for (int i = 1; i < balls.length; i++) {
            if (balls[i] == curColor) {
                cnt++;
            } else {
                out.add(new Run(curColor, cnt));
                curColor = balls[i];
                cnt = 1;
            }
        }
        out.add(new Run(curColor, cnt));
        return out.toArray(new Run[0]);
    }

    /** 反向还原,便于可视化输出 */
    public static int[] decode(Run[] runs) {
        java.util.List<Integer> out = new java.util.ArrayList<>();
        for (Run r : runs) for (int i = 0; i < r.count; i++) out.add(r.color);
        return out.stream().mapToInt(Integer::intValue).toArray();
    }
}

压缩带来的收益在搜索里是实打实的:一条 60 球的随机链,游程数通常只有 25~30,而区间 DP 的复杂度是 O(n^3),n 从 60 降到 30 意味着 8 倍加速。

二、区间 DP 的核心洞察

2.1 子问题定义

设压缩后序列为 runs[0..n-1]。定义:

dp[l][r][k] = 把区间 [l, r] 全部消除,且在区间左侧紧邻位置额外补充 k 个与 runs[l] 同色的球时,所需的最少”额外投球数”。

为什么需要这个 k 维度?因为消除 [l, r] 时,runs[l] 可能与区间外左侧同色球合并。若不加 k,就无法表达”先消中间、让两端合并”这一关键策略。这是区间 DP 在消除类游戏里的标准技巧(与”戳气球”、”消除盒子”属于同一族)。

2.2 转移方程

dp[l][r][k] = min of:
  A) 直接补球消掉左端游程:
     need = 3 - (runs[l].count + k)      // 还差几颗才够三连
     cost = need + dp[l+1][r][0]         // 补球后左端消失,紧接着处理剩余区间
  B) 寻找与左端同色的中点 m,先消 [l+1, m-1],让 l 与 m 合并:
     for m in (l+1..r) where runs[m].color == runs[l].color:
        cost = dp[l+1][m-1][0] + dp[m][r][ min(2, k + runs[l].count) ]

转移 B 的 min(2, ...) 是一个重要优化:合并后的计数只需关心 0/1/2 三档(因为 ≥3 就立即消除了)。把 k 截断在 2 以内,状态数从 O(n^3) 的 k 维度收缩到常数 3。

package com.example.zuma;

import java.util.Arrays;

public class ZumaSolver {
    private final Run[] runs;
    private final int n;
    private final int[][][] memo;
    private static final int INF = 1_000_000;

    public ZumaSolver(Run[] runs) {
        this.runs = runs;
        this.n = runs.length;
        this.memo = new int[n][n][3];
        for (int[][] a : memo) for (int[] b : a) Arrays.fill(b, -1);
    }

    /**
     * 消除 runs[l..r],且左侧已预置 k 颗与 runs[l] 同色的球,返回最少额外球数。
     * k 取值范围 0..2(>=3 时会被立即消除,无需保留)
     */
    private int solve(int l, int r, int k) {
        if (l > r) return 0;
        if (memo[l][r][k] >= 0) return memo[l][r][k];

        // 先把 k 与左端游程的计数合并
        int merged = runs[l].count + k;
        // 若合并后已够三连,则左端整段立即消失
        if (merged >= 3) {
            int res = solve(l + 1, r, 0);
            memo[l][r][k] = res;
            return res;
        }

        // 方案 A:直接补球把左端消掉
        int need = 3 - merged;
        int best = need + solve(l + 1, r, 0);

        // 方案 B:找同色的中间游程 m,先消中间,让 l 与 m 合并
        for (int m = l + 1; m <= r; m++) {
            if (runs[m].color != runs[l].color) continue;
            int mid = solve(l + 1, m - 1, 0);             // 中间先被消掉
            // 合并后 l 与 m 连成一段:计数已达 3 则立即消除(carry=0),否则带上真实计数并截断到 2
            int carry = (runs[m].count + merged >= 3) ? 0 : (runs[m].count + merged);
            int right = solve(m, r, Math.min(2, carry));
            best = Math.min(best, mid + right);
        }

        memo[l][r][k] = best;
        return best;
    }

    /** 整条链的最少额外投球数 */
    public int minBalls() { return solve(0, n - 1, 0); }
}

2.3 正确性直觉

方案 B 的正确性依赖一个不变量:在同色球之间的异色段,无论用什么顺序消除,其”最小代价”与外部上下文无关。这是区间 DP 的可分性条件。证明要点是:消除 [l+1, m-1] 时,唯一可能跨越边界的影响来自 runs[l] 与 runs[m] 的同色性——而它们恰好被 m 的选择固定了,因此子问题独立。

三、插入位置的贪心与反例

初学者常写这样的贪心:“每次射出的球都插到能让消除立即发生的位置”。我们用一个反例说明它为何不是最优:

设链为 蓝蓝红红红蓝(游程 (蓝,2)(红,3)(红...) 稍作调整),炮口当前球为 蓝。贪心会把蓝球插到左端形成 蓝蓝蓝,立即消 3 颗;但更优的策略是把蓝球插到右端 蓝蓝 之后,形成 蓝蓝蓝 的右段,同时红段保持,等下一次红球来时再消——总消除数更高。

用代码对照两种策略能清楚看到差距:

package com.example.zuma;

/** 对照实验:贪心 vs 区间DP */
public class StrategyCompare {
    /** 贪心:只选"立即能消"的位置,若无则插在末尾同色处 */
    public static int greedyBalls(int[] chain, int[] ammo) {
        java.util.List<Integer> list = new java.util.ArrayList<>();
        for (int c : chain) list.add(c);
        int used = 0;
        for (int ball : ammo) {
            int bestPos = -1;
            for (int pos = 0; pos <= list.size(); pos++) {
                list.add(pos, ball);
                Simulator.SimResult r = Simulator.simulate(list);
                list.remove(pos);
                if (r.cleared > 0) { bestPos = pos; break; }   // 贪心:第一个能消的位置
            }
            int pos = bestPos >= 0 ? bestPos : list.size();
            list.add(pos, ball);
            used++;
            Simulator.SimResult r = Simulator.simulate(list);
            list.clear();
            for (int c : r.remaining) list.add(c);
            if (list.isEmpty()) break;
        }
        return used;
    }

    /** 演示反例:打印贪心与最优的差异 */
    public static void demo() {
        int[] chain = {2, 2, 1, 1, 1, 2};
        int[] ammo  = {2, 1, 2, 2};
        int greedy = greedyBalls(chain, ammo);
        Run[] runs = Rle.encode(chain);
        ZumaSolver dp = new ZumaSolver(runs);
        int optimal = dp.minBalls();
        System.out.printf("贪心投球数=%d,区间DP最优=%d%n", greedy, optimal);
    }
}

四、模拟器:消除与连锁

DP 只回答”最少几球”,实际游戏还要把每一步的消除过程演出来。模拟器是独立模块,负责”插入 → 找三连 → 消除 → 合并 → 连锁”。

package com.example.zuma;

import java.util.*;

public final class Simulator {

    public static class SimResult {
        public int cleared;              // 本次总消除球数
        public int[] remaining;          // 剩余链
        public int chainDepth;           // 连锁深度
    }

    /** 插入一颗球到 pos,随后反复消除直到稳定 */
    public static SimResult simulate(List<Integer> chain, int pos, int ball) {
        List<Integer> cur = new ArrayList<>(chain);
        cur.add(pos, ball);
        return stable(cur);
    }

    /** 对当前链反复消除,直到没有三连 */
    public static SimResult stable(List<Integer> cur) {
        int total = 0, depth = 0;
        while (true) {
            int[] seg = findFirstTriple(cur);
            if (seg == null) break;
            int start = seg[0], len = seg[1];
            for (int i = 0; i < len; i++) cur.remove(start);
            total += len;
            depth++;
        }
        SimResult r = new SimResult();
        r.cleared = total;
        r.chainDepth = depth;
        r.remaining = cur.stream().mapToInt(Integer::intValue).toArray();
        return r;
    }

    /** 找到第一段长度 >= 3 的同色连续段,返回 {起点, 长度} */
    private static int[] findFirstTriple(List<Integer> cur) {
        int i = 0;
        while (i < cur.size()) {
            int j = i;
            while (j < cur.size() && cur.get(j).equals(cur.get(i))) j++;
            if (j - i >= 3) return new int[]{i, j - i};
            i = j;
        }
        return null;
    }
}

一个常被忽略的细节:消除后两侧并不一定同色,只有同色才合并成更长的段。上面的 stable 用”反复重扫”处理这一点,写法朴素但对游戏规模(链长 ≤ 100)完全够用;若要上生产,应改成双向链表 + 栈,把每次消除的复杂度从 O(n) 降到 O(1)(因为只需检查消除位置的两端)。

五、复杂度分析与优化方向

方案 时间 空间 适用
暴力枚举插入位置 + BFS 状态去重 指数级 大 仅玩具规模
贪心(立即消除优先) O(m * n^2) O(n) 快但不保证最优
游程编码 + 区间 DP O(n^3) O(n^2) 本文主推,n ≤ 500 可跑
游程编码 + 区间 DP + 记忆化剪枝 实测 O(n^2) 级 O(n^2) 加 k 截断后常数极小

三个工程优化点:

  1. k 截断到 2:这是最大的常数优化,直接把 k 维度从 O(n) 状压到 3。原因是计数 ≥ 3 立即消失,无需记住更大值。
  2. 同色游程预分组:转移 B 里遍历 m 时可以只在”与 runs[l] 同色的下标集合”上迭代,避免无效比较。对整个序列预建 color -> list of indices 映射即可。
  3. 区间长度递增填表:改成自底向上的迭代版,避免递归栈深度问题,同时缓存局部性更好:
public int minBallsIterative() {
    int[][][] f = new int[n + 1][n + 1][3];
    for (int len = 1; len <= n; len++) {
        for (int l = 0; l + len - 1 < n; l++) {
            int r = l + len - 1;
            for (int k = 0; k <= 2; k++) {
                int merged = runs[l].count + k;
                if (merged >= 3) { f[l][r][k] = (l + 1 <= r) ? f[l + 1][r][0] : 0; continue; }
                f[l][r][k] = (3 - merged) + (l + 1 <= r ? f[l + 1][r][0] : 0);
                for (int m = l + 1; m <= r; m++) {
                    if (runs[m].color != runs[l].color) continue;
                    int mid = f[l + 1][m - 1][0];
                    int carry = runs[m].count + merged >= 3 ? 0 : Math.min(2, runs[m].count + merged);
                    int cand = mid + f[m][r][carry];
                    if (cand < f[l][r][k]) f[l][r][k] = cand;
                }
            }
        }
    }
    return f[0][n - 1][0];
}

自底向上版本的空区间(l > r)用 l + 1 <= r 判断跳过,避免开 n+1 的边界技巧,读起来更直观。

六、从”最少球数”到”最大消除”

如果游戏的评分不是”用球最少”而是”消除总数最多”,只需把 DP 的 min 换成 max,并把代价从”补球数”改为”消除数”:

// 目标改为最大化消除:cost 方向反转
// dp[l][r][k] = 最多能消除的球数
// 方案 A:补足三连 -> 消除 merged + need 颗(若 need>0 则实际是补足后消 3 颗)
// 方案 B:中间消除后合并

这个变化不影响最优子结构——区间 DP 的目标函数可替换,这也是它比贪心”更耐用”的原因:换评分规则,算法骨架不变。

七、总结

祖玛这类”链式消除”游戏,表面是反应速度,内核是序列消元的最优顺序问题。三步走:

  1. 游程编码:把连续同色压成一个带计数的元素,n 直接缩水,是所有后续优化的前提。
  2. 区间 DP:dp[l][r][k] 精确表达”左侧已有 k 颗同色球”的上下文,让”先消中间、两端合并”的策略进入状态空间。这是与贪心拉开差距的关键维度。
  3. k 截断 + 同色索引:把 O(n^3) 的常数压到工程可接受。

我见过太多祖玛 AI 停留在”贪心找三连”的水平,然后在长链上被人类玩家轻松击败。差距不在实现技巧,而在于有没有把”插入位置的选择”建成一个可证明最优的 DP。区间 DP 的这套 dp[l][r][k] 模式可以平移到其他消除类游戏——比如”消除盒子””戳气球”——只要问题具备”中间消掉后两端合并”的结构,它就是对的工具。