引言
打地鼠(Whack-a-Mole)是一款考验反应速度的经典街机游戏:地鼠随机从多个洞口冒出,玩家需要在其缩回之前用锤子击中。从算法视角看,打地鼠蕴含了两个有趣的统计问题:一是地鼠出现位置的随机抽样(可建模为带权随机抽样),二是玩家反应时间的流式统计分析(需要在游戏进行过程中实时更新统计量)。本文将引入蓄水池抽样(Reservoir Sampling)算法,将其创新性地应用于打地鼠场景中的在线中位数估计——在不知道总样本量的情况下,实时维护一个反应时间样本集,并高效计算中位数,为动态难度调整提供数据支持。
问题建模
场景定义
假设打地鼠游戏有 $M$ 个洞口,地鼠按某种概率分布随机出现。玩家的每次击打都会产生一个反应时间 $t_i$(从地鼠出现到被击中的毫秒数)。随着游戏进行,反应时间序列 $t_1, t_2, …, t_N$ 以流式方式到达,$N$ 在游戏结束前未知。
核心算法挑战
- 在线中位数估计:需要在每次击打后即时更新中位数,用于动态调整地鼠停留时间(反应快的玩家对应更短停留时间)
- 内存限制:无法存储全部反应时间(流式数据可能无限长)
- 均匀采样保证:蓄水池抽样确保每个反应时间被选入样本集的概率相等
蓄水池抽样原理
蓄水池抽样能够在数据流长度为 $N$ 且 $N$ 未知的情况下,从流中均匀随机选取 $k$ 个样本,保证每个元素被选中的概率均为 $k/N$。
算法流程:
1. 前 $k$ 个元素直接放入蓄水池
2. 对于第 $i$ 个元素($i > k$),以 $k/i$ 的概率替换蓄水池中的某个随机元素
双堆结构维护中位数
在蓄水池中维护两个堆:
– 最大堆(小半部分):存储较小的一半数据,堆顶为较小部分的最大值
– 最小堆(大半部分):存储较大的一半数据,堆顶为较大部分的最小值
中位数规则:
– 当蓄水池大小为奇数时,中位数为最大堆的堆顶
– 当蓄水池大小为偶数时,中位数为两个堆顶的平均值
Java实现
项目结构
src/
├── game/
│ ├── Mole.java // 地鼠模型
│ ├── Hole.java // 洞口模型
│ └── WhackAMoleGame.java // 游戏主控
├── sampling/
│ ├── ReservoirSampler.java // 蓄水池抽样器
│ └── OnlineMedian.java // 在线中位数估计器
├── stats/
│ └── ReactionTimeStats.java // 统计量计算
└── Main.java
Mole.java & Hole.java
package game;
import java.util.Random;
/**
* 地鼠出现事件
*/
public class MoleEvent {
public final long appearTime; // 地鼠出现时间戳(毫秒)
public final int holeIndex; // 出现的洞口编号
public final long stayDuration; // 地鼠计划停留的毫秒数
public MoleEvent(long appearTime, int holeIndex, long stayDuration) {
this.appearTime = appearTime;
this.holeIndex = holeIndex;
this.stayDuration = stayDuration;
}
}
/**
* 玩家击打事件
*/
public class WhackEvent {
public final long whackTime; // 击打时间戳
public final int holeIndex; // 击打的洞口
public final boolean hit; // 是否命中
public final long reactionTime; // 反应时间(出现到击打)
public WhackEvent(long whackTime, int holeIndex, boolean hit, long reactionTime) {
this.whackTime = whackTime;
this.holeIndex = holeIndex;
this.hit = hit;
this.reactionTime = reactionTime;
}
}
/**
* 洞口管理器
* 负责按概率分布选择地鼠出现的洞口
*/
public class HoleManager {
private final int numHoles;
private final double[] probabilities; // 每个洞口的出现概率
private final Random rand;
public HoleManager(int numHoles, Random rand) {
this.numHoles = numHoles;
this.rand = rand;
this.probabilities = new double[numHoles];
// 默认均匀分布
for (int i = 0; i < numHoles; i++) {
probabilities[i] = 1.0 / numHoles;
}
}
/**
* 按概率分布随机选择一个洞口
*/
public int selectHole() {
double r = rand.nextDouble();
double cumulative = 0.0;
for (int i = 0; i < numHoles; i++) {
cumulative += probabilities[i];
if (r <= cumulative) return i;
}
return numHoles - 1;
}
public int getNumHoles() { return numHoles; }
/**
* 动态调整洞口概率(可根据游戏策略改变)
*/
public void setProbability(int hole, double prob) {
probabilities[hole] = prob;
}
}
ReservoirSampler.java — 蓄水池抽样器
package sampling;
import java.util.*;
/**
* 通用蓄水池抽样器
* 从长度未知的数据流中均匀随机抽取k个样本
* 保证每个元素被选中的概率 = k/N(N为流的总长度)
*
* @param <T> 样本类型
*/
public class ReservoirSampler<T> {
private final int capacity; // 蓄水池容量k
private final List<T> reservoir; // 蓄水池
private final Random rand;
private long streamCount; // 已处理的流元素总数
public ReservoirSampler(int capacity, Random rand) {
if (capacity <= 0) {
throw new IllegalArgumentException("蓄水池容量必须大于0");
}
this.capacity = capacity;
this.rand = rand;
this.reservoir = new ArrayList<>(capacity);
this.streamCount = 0;
}
/**
* 处理流中的下一个元素
* @param element 流中的新元素
*/
public void feed(T element) {
streamCount++;
if (reservoir.size() < capacity) {
// 前k个元素直接放入蓄水池
reservoir.add(element);
} else {
// 第i个元素(i > k),以 k/i 的概率替换蓄水池中的随机元素
long i = streamCount; // 当前元素序号(1-based)
int j = rand.nextInt((int) Math.min(i, Integer.MAX_VALUE));
if (j < capacity) {
reservoir.set(j, element);
}
}
}
/**
* 批量处理多个元素
*/
public void feedAll(Collection<T> elements) {
for (T e : elements) feed(e);
}
public List<T> getReservoir() {
return new ArrayList<>(reservoir);
}
public int getCapacity() { return capacity; }
public long getStreamCount() { return streamCount; }
public int getCurrentSize() { return reservoir.size(); }
/**
* 清空蓄水池,重新开始采样
*/
public void reset() {
reservoir.clear();
streamCount = 0;
}
/**
* 验证抽样的均匀性(辅助测试用)
* 理论上每个元素被选中的概率 = min(1, k/i) 的累积效果 = k/N
*/
public static <T> void validateUniformity(List<T> stream, int k, int trials) {
Map<T, Integer> countMap = new HashMap<>();
Random r = new Random(42);
for (int t = 0; t < trials; t++) {
ReservoirSampler<T> sampler = new ReservoirSampler<>(k, r);
for (T e : stream) sampler.feed(e);
for (T e : sampler.getReservoir()) {
countMap.merge(e, 1, Integer::sum);
}
}
double expected = (double) k * trials / stream.size();
System.out.println("均匀性验证(期望每个元素被选中约" + String.format("%.1f", expected) + "次):");
for (Map.Entry<T, Integer> entry : countMap.entrySet()) {
System.out.printf(" 元素 %s: 实际=%d, 期望=%.1f%n",
entry.getKey(), entry.getValue(), expected);
}
}
}
OnlineMedian.java — 在线中位数估计器
package sampling;
import java.util.*;
/**
* 基于双堆结构的在线中位数估计器
* 配合蓄水池抽样,在流式数据环境下实时维护中位数
*
* 数据结构:
* - maxHeap(小半部分):存储较小的那一半数据,堆顶是较小部分的最大值
* - minHeap(大半部分):存储较大的那一半数据,堆顶是较大部分的最小值
* - 中位数:奇数时取maxHeap堆顶,偶数时取两堆顶平均
*/
public class OnlineMedian {
// 最大堆用于存储较小的一半数据(Java的PriorityQueue默认是最小堆,需要反转)
private final PriorityQueue<Long> maxHeap;
// 最小堆用于存储较大的一半数据
private final PriorityQueue<Long> minHeap;
public OnlineMedian() {
// 最大堆:使用Comparator.reverseOrder()
this.maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
// 最小堆:默认自然顺序
this.minHeap = new PriorityQueue<>();
}
/**
* 向数据流中添加一个新值,并维护双堆平衡
* 时间复杂度:O(log n)
*/
public void add(long value) {
if (maxHeap.isEmpty() || value <= maxHeap.peek()) {
maxHeap.offer(value);
} else {
minHeap.offer(value);
}
// 维护平衡:两个堆的大小差不超过1,且maxHeap可以比minHeap多1个元素
rebalance();
}
/**
* 批量添加多个值
*/
public void addAll(Collection<Long> values) {
for (long v : values) add(v);
}
private void rebalance() {
// maxHeap最多比minHeap大1
if (maxHeap.size() > minHeap.size() + 1) {
minHeap.offer(maxHeap.poll());
} else if (minHeap.size() > maxHeap.size()) {
maxHeap.offer(minHeap.poll());
}
}
/**
* 获取当前中位数
* 时间复杂度:O(1)
*/
public double getMedian() {
if (maxHeap.isEmpty()) {
throw new IllegalStateException("数据为空,无法计算中位数");
}
if (maxHeap.size() == minHeap.size()) {
// 偶数个:取两个堆顶的平均值
return (maxHeap.peek() + minHeap.peek()) / 2.0;
} else {
// 奇数个:maxHeap多一个元素,取maxHeap堆顶
return maxHeap.peek();
}
}
/**
* 获取当前 MAD(Median Absolute Deviation,中位数绝对偏差)
* 用于衡量反应时间的离散程度,比标准差更鲁棒
* MAD = median(|x_i - median|)
*/
public double getMAD() {
if (maxHeap.isEmpty()) return 0.0;
double median = getMedian();
List<Long> allData = getAllData();
List<Double> absDeviations = new ArrayList<>();
for (long v : allData) {
absDeviations.add(Math.abs(v - median));
}
Collections.sort(absDeviations);
int n = absDeviations.size();
if (n % 2 == 1) {
return absDeviations.get(n / 2);
} else {
return (absDeviations.get(n / 2 - 1) + absDeviations.get(n / 2)) / 2.0;
}
}
/**
* 获取四分位距 IQR = Q3 - Q1
*/
public double getIQR() {
List<Long> sorted = getAllData();
Collections.sort(sorted);
int n = sorted.size();
if (n < 4) return 0.0;
double q1 = getPercentile(sorted, 0.25);
double q3 = getPercentile(sorted, 0.75);
return q3 - q1;
}
private double getPercentile(List<Long> sorted, double p) {
double pos = p * (sorted.size() - 1);
int lower = (int) Math.floor(pos);
int upper = (int) Math.ceil(pos);
double weight = pos - lower;
return sorted.get(lower) * (1 - weight) + sorted.get(upper) * weight;
}
public List<Long> getAllData() {
List<Long> all = new ArrayList<>();
all.addAll(maxHeap);
all.addAll(minHeap);
return all;
}
public int size() {
return maxHeap.size() + minHeap.size();
}
public void clear() {
maxHeap.clear();
minHeap.clear();
}
@Override
public String toString() {
return String.format("OnlineMedian{size=%d, median=%.1f, MAD=%.1f, IQR=%.1f}",
size(), getMedian(), getMAD(), getIQR());
}
}
ReactionTimeStats.java — 反应时间统计分析器
package stats;
import sampling.OnlineMedian;
import sampling.ReservoirSampler;
import java.util.*;
/**
* 打地鼠游戏反应时间统计分析器
* 结合蓄水池抽样和在线中位数,为动态难度调整提供实时统计支持
*/
public class ReactionTimeStats {
private final ReservoirSampler<Long> sampler; // 蓄水池抽样器
private final OnlineMedian onlineMedian; // 在线中位数(基于全部数据)
private final int reservoirSize; // 蓄水池容量
// 动态难度调整参数
private long baseStayDuration; // 基础停留时间(毫秒)
private long minStayDuration; // 最短停留时间
private long maxStayDuration; // 最长停留时间
public ReactionTimeStats(int reservoirSize, long baseStayDuration,
long minStayDuration, long maxStayDuration,
Random rand) {
this.reservoirSize = reservoirSize;
this.sampler = new ReservoirSampler<>(reservoirSize, rand);
this.onlineMedian = new OnlineMedian();
this.baseStayDuration = baseStayDuration;
this.minStayDuration = minStayDuration;
this.maxStayDuration = maxStayDuration;
}
/**
* 记录一次击打事件的反应时间
*/
public void recordReactionTime(long reactionTime) {
sampler.feed(reactionTime);
onlineMedian.add(reactionTime);
}
/**
* 基于蓄水池样本计算的中位数估计
* 适用于大数据流场景(内存受限)
*/
public double getReservoirMedian() {
List<Long> samples = sampler.getReservoir();
if (samples.isEmpty()) return baseStayDuration;
List<Long> sorted = new ArrayList<>(samples);
Collections.sort(sorted);
int n = sorted.size();
if (n % 2 == 1) return sorted.get(n / 2);
return (sorted.get(n / 2 - 1) + sorted.get(n / 2)) / 2.0;
}
/**
* 基于全部数据的精确中位数(仅保留最近数据)
* 如果数据量超过蓄水池容量,则回退到蓄水池估计
*/
public double getMedian() {
if (onlineMedian.size() <= reservoirSize) {
return onlineMedian.getMedian();
}
return getReservoirMedian();
}
/**
* 动态调整地鼠停留时间
* 策略:中位数反应时间越短,停留时间越短(难度越高)
* @return 调整后的停留时间(毫秒)
*/
public long adjustStayDuration() {
double median = getMedian();
double mad = onlineMedian.getMAD();
// 自适应公式:
// 停留时间 = 基础时间 * (反应时间中位数 / 基准反应时间)
// 加入MAD作为"反应时间稳定性"的调节因子
long baselineReaction = 500; // 基准反应时间500ms
double ratio = median / baselineReaction;
// MAD越小说明反应越稳定,可以适当降低停留时间
double stabilityFactor = Math.max(0.5, 1.0 - (mad / 1000.0) * 0.3);
long adjusted = (long) (baseStayDuration * ratio * stabilityFactor);
return Math.max(minStayDuration, Math.min(maxStayDuration, adjusted));
}
/**
* 判断玩家当前状态
*/
public PlayerState assessPlayerState() {
double median = getMedian();
double mad = onlineMedian.getMAD();
if (median < 300 && mad < 100) return PlayerState.EXPERT;
if (median < 500 && mad < 200) return PlayerState.SKILLED;
if (median < 800) return PlayerState.NOVICE;
return PlayerState.BEGINNER;
}
/**
* 打印当前统计报告
*/
public void printReport() {
System.out.println("=== 反应时间统计报告 ===");
System.out.println(" 总样本数: " + sampler.getStreamCount());
System.out.println(" 蓄水池大小: " + sampler.getCurrentSize() + "/" + reservoirSize);
System.out.println(" 蓄水池中位数: " + String.format("%.1f", getReservoirMedian()) + " ms");
if (onlineMedian.size() <= reservoirSize) {
System.out.println(" 精确中位数: " + String.format("%.1f", onlineMedian.getMedian()) + " ms");
}
System.out.println(" MAD: " + String.format("%.1f", onlineMedian.getMAD()) + " ms");
System.out.println(" IQR: " + String.format("%.1f", onlineMedian.getIQR()) + " ms");
System.out.println(" 玩家水平: " + assessPlayerState());
System.out.println(" 建议停留时间: " + adjustStayDuration() + " ms");
}
public enum PlayerState {
BEGINNER("新手"), NOVICE("初级"), SKILLED("熟练"), EXPERT("专家");
public final String label;
PlayerState(String label) { this.label = label; }
}
}
WhackAMoleGame.java — 游戏主控
package game;
import stats.ReactionTimeStats;
import java.util.*;
/**
* 打地鼠游戏模拟器
* 演示蓄水池抽样与在线中位数在动态难度调整中的应用
*/
public class WhackAMoleGame {
private final HoleManager holeManager;
private final ReactionTimeStats stats;
private final Random rand;
private long currentStayDuration; // 当前地鼠停留时间
private int score;
private int totalMoles;
private int hitMoles;
public WhackAMoleGame(int numHoles, int reservoirSize, Random rand) {
this.rand = rand;
this.holeManager = new HoleManager(numHoles, rand);
this.stats = new ReactionTimeStats(
reservoirSize,
1500, // 基础停留1.5秒
400, // 最短0.4秒
3000, // 最长3秒
rand
);
this.currentStayDuration = 1500;
this.score = 0;
}
/**
* 模拟一局游戏
* @param numRounds 进行的轮数
*/
public void play(int numRounds) {
System.out.println("=== 打地鼠游戏开始 ===");
System.out.println("洞口数量: " + holeManager.getNumHoles());
System.out.println("蓄水池容量: " + stats);
for (int round = 1; round <= numRounds; round++) {
// 1. 地鼠出现
int hole = holeManager.selectHole();
long appearTime = System.currentTimeMillis();
MoleEvent mole = new MoleEvent(appearTime, hole, currentStayDuration);
// 2. 模拟玩家反应(反应时间与当前停留时间和玩家"能力"相关)
long reactionTime = simulatePlayerReaction();
boolean hit = reactionTime <= currentStayDuration;
long whackTime = appearTime + reactionTime;
WhackEvent whack = new WhackEvent(whackTime, hole, hit, reactionTime);
// 3. 记录统计
if (hit) {
stats.recordReactionTime(reactionTime);
score += 10;
hitMoles++;
}
totalMoles++;
// 4. 动态调整难度
if (round % 10 == 0) {
currentStayDuration = stats.adjustStayDuration();
System.out.println("\n--- 第" + round + "轮后难度调整 ---");
stats.printReport();
}
}
// 最终报告
System.out.println("\n=== 游戏结束 ===");
System.out.println("总轮数: " + numRounds);
System.out.println("命中率: " + hitMoles + "/" + totalMoles +
" (" + String.format("%.1f", 100.0 * hitMoles / totalMoles) + "%)");
System.out.println("总得分: " + score);
stats.printReport();
}
/**
* 模拟玩家反应时间
* 使用正态分布模拟,均值随游戏进行逐渐降低(玩家变强)
*/
private long simulatePlayerReaction() {
// 模拟一个反应时间在 200ms - 1200ms 之间的玩家
double mean = 600 - stats.getMedian() * 0.2; // 自适应:反应越快均值越低
double std = 150;
long reaction = (long) (mean + rand.nextGaussian() * std);
return Math.max(100, Math.min(2000, reaction));
}
public int getScore() { return score; }
}
Main.java
import game.WhackAMoleGame;
import sampling.ReservoirSampler;
import java.util.*;
public class Main {
public static void main(String[] args) {
Random rand = new Random(42);
// 演示1:蓄水池抽样的均匀性验证
System.out.println("=== 演示1:蓄水池抽样均匀性验证 ===");
List<Integer> stream = new ArrayList<>();
for (int i = 1; i <= 100; i++) stream.add(i);
ReservoirSampler.validateUniformity(stream, 10, 10000);
// 演示2:打地鼠游戏与动态难度调整
System.out.println("\n=== 演示2:打地鼠游戏(蓄水池+在线中位数) ===");
WhackAMoleGame game = new WhackAMoleGame(9, 50, rand);
game.play(100);
}
}
算法复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 蓄水池feed | O(1) | O(k) | k为蓄水池容量 |
| 在线中位数add | O(log n) | O(n) | n为当前数据量(受限于蓄水池时则为O(k)) |
| 中位数查询 | O(1) | O(1) | 直接读取堆顶 |
| MAD计算 | O(n log n) | O(n) | 需要遍历全部数据 |
| 难度调整 | O(1) | O(1) | 基于已缓存的统计量 |
总结
本文将蓄水池抽样算法创新性地应用于打地鼠游戏的反应时间分析,实现了在流式数据环境下的在线中位数估计。该方法的核心优势包括:
- 内存恒定:无论游戏进行多少轮,内存占用始终为O(k),k为蓄水池容量
- 统计无偏:蓄水池抽样保证每个反应时间被选中的概率相等,统计结果具有代表性
- 实时响应:双堆结构使中位数查询为O(1),支持每轮游戏结束后的即时难度调整
- 鲁棒度量:引入MAD和IQR替代标准差,对异常反应时间(如玩家分神)更具鲁棒性
该框架可扩展至任何需要”流式数据统计+动态决策”的游戏AI场景,如音乐节奏游戏的判定窗口自适应、赛车游戏的AI速度匹配等。