一、游戏场景:打地鼠其实是一道最优化题
街机厅里的打地鼠(Whac-A-Mole)看起来只是比谁手快:地鼠随机冒头,玩家在 3×3 的洞口间挥锤。但只要稍微认真地玩几局,就会发现它在决策层面有明确的结构:
- 时间窗口是硬的:一只地鼠只在
[t_i, t_i + τ]之间可被打中,错过就没了; - 挥锤是有代价的:锤子需要蓄能,蓄能越久,下一次挥出去的动能代价越大;
- 移动是有代价的:从一个洞口到另一个洞口需要位移时间,位移也会消耗蓄能;
- 收益不可简单相加:单次命中的价值取决于当时锤子处于什么蓄能状态。
把这四条翻译成数学形式,会得到一个非常典型的动态规划(DP)递推式;而这个递推式恰好具备决策点单调的结构,于是可以用斜率优化(Convex Hull Trick,简称 CHT)把 O(n²) 的转移压缩到 O(n)。本文用一个单线轨道的”机械打地鼠机”作为模型载体(地鼠沿一条传送带依次冒头,锤子沿轨道往复),完整实现这套决策算法。
二、把游戏抽象成递推式
2.1 数据与代价模型
设第 i 只地鼠冒头时刻为 t_i,价值为 v_i,洞口坐标为 x_i(沿轨道的整数坐标)。锤子有蓄能特性:连续两击之间,蓄能时长记为 t_i - t_j;同时位移距离 |x_i - x_j| 也要折算成等效的蓄能占用。定义动作代价前缀 S_i:
S_i = Σ_{k=1..i} [ (t_k - t_{k-1}) + λ · |x_k - x_{k-1}| ]
其中 S_0 = 0,t_0 = 0,x_0 = 0,λ 是位移折算系数。动能代价模型为:
E(j → i) = p · (S_i - S_j)² / q
为什么是平方? 因为动能 E = ½ m ω²,锤头的角速度近似与蓄能时长成正比,所以代价对蓄能时长的依赖是二次的。这一点是整个算法能成立的关键:二次项展开后会退化成”一条直线”,而我们要做的正是在一堆直线里维护上包络。
2.2 递推式
令 dp[i] 表示「以第 i 只地鼠作为最后一击」时的最大净收益:
dp[i] = v_i + max_{0 ≤ j < i} { dp[j] - p·(S_i - S_j)²/q }
展开平方项:
dp[i] = v_i - p·S_i²/q + max_j { (dp[j] - p·S_j²/q) + (2p·S_j/q) · S_i }
2.3 直线化:平方项变成斜率
把 m_j = 2p·S_j/q 看作斜率、b_j = dp[j] - p·S_j²/q 看作截距,则右侧的 max 就是”在所有直线 L_j(x) = m_j·x + b_j 上,取 x = S_i 处的最大值”:
dp[i] = v_i - p·S_i²/q + max_j { m_j·S_i + b_j }
三条性质决定了算法选型:
- 斜率单调:
S_j随j单调递增,所以m_j单调递增; - 查询单调:查询点
x = S_i也单调递增; - 只增不删:我们只不断加入新直线,从不任意删除。
斜率单调 + 查询单调 = 标准的单调队列凸包,每次插入摊还 O(1)、每次查询摊还 O(1),总复杂度 O(n)。
三、精度问题:不要用 double 存 DP
工程实现里最容易翻车的坑是精度。p·(S_i - S_j)²/q 是分数,用 double 存 DP 值时,误差会沿转移链累积,几百步后就可能把次优决策点误判为最优。
解决办法是整体消去分母:把所有 DP 值按 q 倍缩放,令 D[i] = dp[i]·q,于是递推式全整数:
D[i] = q·v_i + max_{0 ≤ j < i} { D[j] - p·(S_i - S_j)² }
对应直线参数变成 m_j = 2p·S_j、b_j = D[j] - p·S_j²、查询点 x = S_i,全部是 long。DP 值本身因此完全精确,唯一涉及”几何判断”的地方只剩”凸包里哪条直线该被淘汰”——那里我们用交叉相乘,并用 BigInteger 规避溢出。
四、Java 实现
4.1 项目结构
mole-hammer/
├── src/main/java/com/algodaily/mole/
│ ├── Mole.java // 地鼠数据结构
│ ├── KineticModel.java // 动能代价模型与前缀代价推导
│ ├── Plan.java // 求解结果(收益 + 打击序列)
│ ├── ConvexHullPlanner.java // 单调队列凸包,O(n)
│ ├── LiChaoPlanner.java // 李超树,O(n log n),支持乱序/滑窗
│ ├── BruteForcePlanner.java // O(n²) 朴素 DP,对拍基准
│ └── HammerGameApp.java // 入口:演示 + 随机对拍
4.2 数据结构与代价模型
package com.algodaily.mole;
/** 一只地鼠:冒头时刻、轨道坐标、命中价值 */
public final class Mole {
public final long time; // 冒头时刻 t_i(秒,整数)
public final int x; // 轨道坐标 x_i
public final long value; // 命中价值 v_i
public Mole(long time, int x, long value) {
this.time = time;
this.x = x;
this.value = value;
}
@Override
public String toString() {
return String.format("Mole(t=%d, x=%d, v=%d)", time, x, value);
}
}
package com.algodaily.mole;
/**
* 动能代价模型。
* 把「蓄能时长」与「轨道位移」折算成严格递增的动作代价前缀 S_i,
* 并保留动能系数的有理数形式 p/q,避免浮点误差。
*/
public final class KineticModel {
public final long[] prefixCost; // S_0..S_n,严格递增
public final int p; // 动能系数分子
public final int q; // 动能系数分母
public KineticModel(Mole[] moles, int p, int q, long moveCostPerUnit) {
if (q <= 0) throw new IllegalArgumentException("q 必须为正");
this.p = p;
this.q = q;
int n = moles.length;
this.prefixCost = new long[n + 1];
long acc = 0;
for (int i = 0; i < n; i++) {
// 蓄能耗时 = 与上一只地鼠的时间差(首只从 0 时刻起算)
long dt = (i == 0) ? moles[0].time : moles[i].time - moles[i - 1].time;
long dx = (i == 0) ? Math.abs(moles[0].x) : Math.abs(moles[i].x - moles[i - 1].x);
acc += dt + moveCostPerUnit * dx; // 位移折算为等效蓄能时间
prefixCost[i + 1] = acc;
}
}
/** 打出第 i 只(1-based)时的前缀代价 S_i */
public long s(int i) { return prefixCost[i]; }
public int size() { return prefixCost.length - 1; }
}
package com.algodaily.mole;
/** 求解结果:缩放后的净收益(真实收益 = scaledProfit / q)+ 命中的地鼠下标序列 */
public final class Plan {
public final long scaledProfit;
public final int[] hits; // 1-based 地鼠下标,按打击顺序
public Plan(long scaledProfit, int[] hits) {
this.scaledProfit = scaledProfit;
this.hits = hits;
}
public double profit(int q) { return (double) scaledProfit / q; }
}
4.3 单调队列凸包(核心,O(n))
这个类同时承担两件事:维护上包络,以及在查询时一并返回最优值与给出它的决策下标,从而把”路径还原”也做到 O(1)。
package com.algodaily.mole;
import java.math.BigInteger;
/**
* 单调队列维护上凸包(Convex Hull Trick),求 max(m_j * x + b_j)。
*
* 前提:斜率单调递增、查询 x 单调递增、只插入从不任意删除。
* 单次插入摊还 O(1),单次查询摊还 O(1),整体 O(n)。
*/
public final class ConvexHullPlanner {
/** 查询结果:最优值与该直线对应的 DP 决策下标 j */
public static final class Hit {
public final long value;
public final int origin;
Hit(long value, int origin) { this.value = value; this.origin = origin; }
}
private final long[] m; // 斜率
private final long[] b; // 截距
private final int[] origin; // 该直线来自哪个 j
private int head = 0; // 队首指针(当前最优直线)
private int tail = 0; // 队尾指针(下一个空槽)
public ConvexHullPlanner(int capacity) {
this.m = new long[capacity];
this.b = new long[capacity];
this.origin = new int[capacity];
}
/**
* 判断 l2 是否被 l1、l3 完全遮蔽(上凸包语义)。
* 交点 x12 = (b1 - b2) / (m2 - m1),x23 = (b2 - b3) / (m3 - m2);
* 三条直线斜率递增,故两个分母均为正,交叉相乘不改变不等号方向:
* x12 >= x23 <=> (b1 - b2)*(m3 - m2) >= (b2 - b3)*(m2 - m1)
* 乘积量级可达 1e27,必须 BigInteger 才不溢出。
*/
private static boolean redundant(long m1, long b1, long m2, long b2, long m3, long b3) {
BigInteger left = BigInteger.valueOf(b1 - b2).multiply(BigInteger.valueOf(m3 - m2));
BigInteger right = BigInteger.valueOf(b2 - b3).multiply(BigInteger.valueOf(m2 - m1));
return left.compareTo(right) >= 0;
}
/** 插入一条直线(斜率必须单调不降);originIndex 用于回溯决策 */
public void add(long slope, long intercept, int originIndex) {
int pos = tail;
// 同斜率:求 max 时只保留截距更大的那条
if (pos > head && m[pos - 1] == slope) {
if (b[pos - 1] >= intercept) return; // 新线更差,直接丢弃
pos--; // 旧线更差,弹出后继续
}
// 维护上凸包:弹掉被完全遮蔽的直线
while (pos - head >= 2
&& redundant(m[pos - 2], b[pos - 2], m[pos - 1], b[pos - 1], slope, intercept)) {
pos--;
}
m[pos] = slope;
b[pos] = intercept;
origin[pos] = originIndex;
tail = pos + 1;
}
/** 查询 x 处的最大值与决策下标(x 必须单调不降) */
public Hit query(long x) {
while (head + 1 < tail && m[head] * x + b[head] <= m[head + 1] * x + b[head + 1]) {
head++; // 下一条直线在 x 处更优,队首永久失效
}
return new Hit(m[head] * x + b[head], origin[head]);
}
public boolean isEmpty() { return tail <= head; }
}
为什么
m*x+b不会溢出? 取p ≤ 1000、S_n ≤ 10⁶,则|m| = 2p·S ≤ 2·10⁹,|m·x| ≤ 2·10¹⁵;|b| = |D - p·S²| ≤ 10¹⁵。相加仍在long(9.22·10¹⁸)的安全区内。但redundant的交叉乘积会到10²⁷,所以那里单独用BigInteger——这是”同一条式子里两处、量级完全不同”的典型例子。
4.4 主求解器
package com.algodaily.mole;
/** 用单调队列凸包求解打地鼠最优击打序列 */
public final class HammerSolver {
public static Plan solve(Mole[] moles, KineticModel model) {
int n = moles.length;
long p = model.p, q = model.q;
long[] D = new long[n + 1]; // D[i] = dp[i] * q
int[] parent = new int[n + 1]; // 决策回溯指针
ConvexHullPlanner hull = new ConvexHullPlanner(n + 1);
D[0] = 0;
parent[0] = -1;
hull.add(0, 0, 0); // 起点直线:m = 2p*S_0 = 0,b = D[0] - p*S_0^2 = 0
long bestScaled = 0; // 「一只都不打」的保底策略,收益为 0
int bestIdx = 0;
for (int i = 1; i <= n; i++) {
long si = model.s(i);
ConvexHullPlanner.Hit hit = hull.query(si);
D[i] = q * moles[i - 1].value + hit.value - p * si * si;
parent[i] = hit.origin;
if (D[i] > bestScaled) {
bestScaled = D[i];
bestIdx = i;
}
// 当前状态作为新直线入包,供后续 i 使用
hull.add(2 * p * si, D[i] - p * si * si, i);
}
return new Plan(bestScaled, restorePath(parent, bestIdx));
}
private static int[] restorePath(int[] parent, int end) {
int len = 0;
for (int k = end; k > 0; k = parent[k]) len++;
int[] path = new int[len];
for (int k = end, idx = len - 1; k > 0; k = parent[k]) path[idx--] = k;
return path;
}
}
一个容易忽略的细节:hull.add 必须在 query 之后。因为第 i 次查询只能用 j < i 的决策点,先把 i 自己插进去等于允许”原地连击”,会让 DP 出现自环。顺序错了不会报错,但答案会偏大——这类 bug 只能靠对拍抓出来。
4.5 李超树版本:支持乱序插入与滑窗约束
真实的打地鼠机常常有”蓄能上限”:两只地鼠如果隔太久,锤子蓄能溢出,这一击反而打不出去。可以给每只地鼠配置上限 A_i,于是合法决策集合变成滑动窗口 S_i - S_j ≤ A_i。此时”从队首永久出队”的规则不再成立(中间可能跳出一条还新鲜的直线),稳妥的替代是李超树(Li Chao Tree):在离散化的 x = S_i 坐标上做插入 + 单点查询,O(log n)。
package com.algodaily.mole;
/**
* 李超树(Li Chao Tree):维护一堆直线,支持任意时刻插入、任意点查询最大值。
* 所有比较都用 long 求值,天然精确;坐标在构造时离散化。
* 相比单调队列凸包的优势:斜率无需单调、可叠加滑窗式过期过滤。
*/
public final class LiChaoPlanner {
private static final long NEG = Long.MIN_VALUE / 4;
private final long[] xs; // 离散化后的查询点(单调递增)
private final long[] slopes;
private final long[] inters;
private final boolean[] used;
public LiChaoPlanner(long[] queryPoints) {
long[] sorted = queryPoints.clone();
java.util.Arrays.sort(sorted);
this.xs = sorted;
int size = 4 * Math.max(1, xs.length);
this.slopes = new long[size];
this.inters = new long[size];
this.used = new boolean[size];
}
private long eval(long m, long b, long x) { return m * x + b; }
public void add(long m, long b) { insert(1, 0, xs.length - 1, m, b); }
private void insert(int node, int l, int r, long m, long b) {
if (!used[node]) {
used[node] = true;
slopes[node] = m;
inters[node] = b;
return;
}
int mid = (l + r) >>> 1;
long xl = xs[l], xm = xs[mid];
long curM = slopes[node], curB = inters[node];
// 先保证节点处保留「在 xm 上更优」的直线
if (eval(m, b, xm) > eval(curM, curB, xm)) {
slopes[node] = m; inters[node] = b;
m = curM; b = curB;
}
if (l == r) return;
// 再用左端点判断新线是否在左侧更优,决定往哪个子树下放
if (eval(m, b, xl) > eval(slopes[node], inters[node], xl)) {
insert(node << 1, l, mid, m, b);
} else {
insert(node << 1 | 1, mid + 1, r, m, b);
}
}
public long query(long x) { return query(1, 0, xs.length - 1, lowerBound(x), x); }
private long query(int node, int l, int r, int pos, long x) {
long res = used[node] ? eval(slopes[node], inters[node], x) : NEG;
if (l == r) return res;
int mid = (l + r) >>> 1;
if (pos <= mid) return Math.max(res, query(node << 1, l, mid, pos, x));
return Math.max(res, query(node << 1 | 1, mid + 1, r, pos, x));
}
private int lowerBound(long x) {
int lo = 0, hi = xs.length - 1;
while (lo < hi) {
int mid = (lo + hi) >>> 1;
if (xs[mid] < x) lo = mid + 1; else hi = mid;
}
return lo;
}
}
滑窗约束的落地:给 add 增加一个 expireAt 参数(该直线在 S 超过多少后失效),在 query 中若命中节点已过期就把贡献视为 NEG。李超树本身不支持删除,工程上通常两种做法:
- 惰性墓碑 + 双堆校验:查询时检查直线所有者
j是否过期,过期则屏蔽该节点贡献; - 时间分治(CDQ 分治):把转移按时间轴切成
log n段离线处理,每段用静态凸包,天然规避删除。
对本文这个尺度的游戏,方案 1 已经够用。
4.6 朴素 DP 对拍
package com.algodaily.mole;
/** O(n^2) 朴素 DP:正确性基准 */
public final class BruteForcePlanner {
public static Plan solve(Mole[] moles, KineticModel model) {
int n = moles.length;
long[] D = new long[n + 1];
int[] parent = new int[n + 1];
D[0] = 0;
parent[0] = -1;
long best = 0;
int bestIdx = 0;
for (int i = 1; i <= n; i++) {
long si = model.s(i);
long bestCand = Long.MIN_VALUE / 4;
int bestJ = 0;
for (int j = 0; j < i; j++) {
long d = si - model.s(j);
long cand = D[j] - (long) model.p * d * d;
if (cand > bestCand) { bestCand = cand; bestJ = j; }
}
D[i] = (long) model.q * moles[i - 1].value + bestCand;
parent[i] = bestJ;
if (D[i] > best) { best = D[i]; bestIdx = i; }
}
return new Plan(best, restore(parent, bestIdx));
}
private static int[] restore(int[] parent, int end) {
int len = 0;
for (int k = end; k > 0; k = parent[k]) len++;
int[] path = new int[len];
for (int k = end, idx = len - 1; k > 0; k = parent[k]) path[idx--] = k;
return path;
}
}
4.7 演示入口与随机对拍
package com.algodaily.mole;
import java.util.Arrays;
import java.util.Random;
/** 入口:先演示一局,再做 1000 组随机对拍 */
public final class HammerGameApp {
public static void main(String[] args) {
demoOneRound();
stressTest();
}
private static void demoOneRound() {
// 地鼠在 t=1..15 依次冒头,坐标沿轨道来回摆动
Mole[] moles = {
new Mole(1, 0, 30),
new Mole(3, 2, 45),
new Mole(4, 1, 12),
new Mole(7, 5, 80),
new Mole(9, 3, 25),
new Mole(12, 6, 95),
new Mole(15, 1, 40),
};
KineticModel model = new KineticModel(moles, 3, 2, 1L);
Plan fast = HammerSolver.solve(moles, model);
Plan slow = BruteForcePlanner.solve(moles, model);
System.out.println("=== 单调队列凸包 O(n) ===");
System.out.printf("净收益 = %.3f%n", fast.profit(model.q));
System.out.println("打击序列 = " + Arrays.toString(fast.hits));
System.out.println("=== 朴素 DP O(n^2) ===");
System.out.printf("净收益 = %.3f%n", slow.profit(model.q));
System.out.println("打击序列 = " + Arrays.toString(slow.hits));
System.out.println("收益完全一致 = " + (fast.scaledProfit == slow.scaledProfit));
}
/** 随机对拍:验证凸包版本与朴素版本的缩放收益处处相同 */
private static void stressTest() {
Random rnd = new Random(20261101L);
int mismatch = 0;
for (int iter = 0; iter < 1000; iter++) {
int n = 1 + rnd.nextInt(30);
Mole[] moles = new Mole[n];
long t = 0;
for (int i = 0; i < n; i++) {
t += 1 + rnd.nextInt(5); // 时间严格递增
moles[i] = new Mole(t, rnd.nextInt(9), 5 + rnd.nextInt(100));
}
int p = 1 + rnd.nextInt(4);
int q = 1 + rnd.nextInt(4);
int lambda = rnd.nextInt(3);
KineticModel model = new KineticModel(moles, p, q, lambda);
long a = HammerSolver.solve(moles, model).scaledProfit;
long b = BruteForcePlanner.solve(moles, model).scaledProfit;
if (a != b) {
mismatch++;
System.out.println("不一致 iter=" + iter + " cht=" + a + " brute=" + b);
}
}
System.out.println("对拍完成,不一致次数 = " + mismatch);
}
}
跑一次,你会看到两个版本的收益完全相同:30 只地鼠时,朴素版做约 465 次转移,凸包版只做 30 次查询与 30 次插入。
五、复杂度与工程边界
| 方案 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 朴素 DP | O(n²) |
O(n) |
n ≤ 2000 的验证 / 对拍基准 |
| 单调队列凸包 | O(n) 摊还 |
O(n) |
斜率单调 + 查询单调 + 无删除 |
| 李超树 | O(n log n) |
O(n) |
斜率乱序,或叠加滑窗过期 |
| CDQ 分治 + 静态凸包 | O(n log² n) |
O(n) |
带删除的离线版本 |
四条实现纪律:
- 斜率单调性是前提,不是巧合。如果地鼠冒头时间乱序,
S_i不再单调,单调队列会静默给出错误答案。判断依据就是S序列是否非降,这一步必须在建模时确认,而不是等出错后再调试。 - 分母必须提前消掉。
p/q的有理系数要在递推前乘到D上,否则double误差会在几百步后改变决策点。 - 溢出边界要算清。同一组式子里,
m·x + b是10¹⁵量级(long够用),而redundant的交叉乘积是10²⁷量级(必须BigInteger)。把这两处分开处理,别用一个”统一方案”糊过去。 - 插入与查询的顺序。
add(i)只能在query(i)之后,否则 DP 出现”原地连击”的自环。
六、这个模型能带到哪里
打地鼠只是一个载体,这套结构其实是”带二次代价的顺序决策“的通用模板:
- 音游连击判定:连击间隔对分数的影响是二次衰减,
dp转移同样是平方代价,可直接套斜率优化; - 限时抢单 / 派单调度:骑手空驶成本按等待时长平方增长时,模型与本文同构;
- 机器人的蓄能挥击:物理里
E = ½mω²就是二次项,”动能预算下的动作排序”本质都是同一个凸包。
理解”为什么平方项能变成一条直线”比记住凸包模板更重要:只要代价对决策变量的依赖能被拆成 x 的线性组合,max 就变成了”在一堆直线上取包络”——这就是斜率优化的全部秘密。