引言
挑棍(Pick-up Sticks)是一款风靡全球的童年益智游戏。玩家将一把彩色细棍随意撒落在桌面上,形成相互交叠的复杂结构,然后轮流抽取一根细棍,每次只能移动目标棍子而不触动其他棍子。一旦移动过程中触碰到其他棍子,回合即告结束。这一看似靠运气的游戏,实际上蕴含着丰富的几何判断与策略选择问题:如何判断一根棍子是否”安全”可抽?在众多可抽取的棍子中,哪一根能让后续局面最有利?本文将用Java实现挑棍游戏的稳定结构检测系统,讲解线段相交判定算法与贪心最优抽取策略。
算法原理
线段相交判定
挑棍的核心问题是线段相交检测:给定两根线段,判断它们是否在平面内相交。常用的方法是跨立实验(Orientation Test),基于向量叉积的符号来判断点的相对位置。
对于三点 $A$, $B$, $C$,定义方向函数:
$$orientation(A, B, C) = (B_x – A_x)(C_y – A_y) – (B_y – A_y)(C_x – A_x)$$
- 结果为正值:$C$ 在向量 $AB$ 的左侧
- 结果为负值:$C$ 在向量 $AB$ 的右侧
- 结果为零:$A$, $B$, $C$ 三点共线
两条线段 $AB$ 和 $CD$ 相交的充要条件是:
– $A$ 和 $B$ 分别位于直线 $CD$ 的两侧(或其中一点在直线上)
– $C$ 和 $D$ 分别位于直线 $AB$ 的两侧(或其中一点在直线上)
贪心抽取策略
判断一根棍子是否可安全抽取,需要检查它是否与任何其他棍子相交。若不相交,则该棍子位于结构的最上层,可直接抽取。
贪心策略:在每一轮中,优先抽取被压棍子数最少的棍子。这种策略的直觉是:被压最少的棍子最容易抽出,且抽出后对整体结构影响最小,为后续操作保留更多选择。
Java实现
1. 几何基础类
/**
* 二维点:表示细棍的端点坐标
*/
public class Point {
public final double x;
public final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return String.format("(%.2f, %.2f)", x, y);
}
}
/**
* 线段:表示一根细棍
*/
public class Stick {
public final Point p1; // 起点
public final Point p2; // 终点
public final int id; // 编号
public final String color; // 颜色
public Stick(int id, Point p1, Point p2, String color) {
this.id = id;
this.p1 = p1;
this.p2 = p2;
this.color = color;
}
/** 计算线段中点 */
public Point getCenter() {
return new Point((p1.x + p2.x) / 2.0, (p1.y + p2.y) / 2.0);
}
/** 计算线段长度 */
public double getLength() {
return Math.hypot(p2.x - p1.x, p2.y - p1.y);
}
@Override
public String toString() {
return String.format("Stick%d[%s] %s-%s", id, color, p1, p2);
}
}
2. 线段相交检测引擎
/**
* 几何引擎:提供线段相交检测与方向判断
*/
public class GeometryEngine {
/**
* 计算三点的方向(叉积符号)
* @return >0 左侧, <0 右侧, =0 共线
*/
public static double orientation(Point a, Point b, Point c) {
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}
/**
* 判断点p是否在线段ab上(假设三点共线)
*/
public static boolean onSegment(Point a, Point b, Point p) {
return Math.min(a.x, b.x) <= p.x && p.x <= Math.max(a.x, b.x)
&& Math.min(a.y, b.y) <= p.y && p.y <= Math.max(a.y, b.y);
}
/**
* 判断两条线段是否相交(包含端点接触的情况)
*/
public static boolean segmentsIntersect(Stick s1, Stick s2) {
Point a = s1.p1, b = s1.p2;
Point c = s2.p1, d = s2.p2;
double o1 = orientation(a, b, c);
double o2 = orientation(a, b, d);
double o3 = orientation(c, d, a);
double o4 = orientation(c, d, b);
// 一般情况:两线段互相跨立
if (o1 * o2 < 0 && o3 * o4 < 0) {
return true;
}
// 特殊情况:某点恰好在另一条线段上
if (o1 == 0 && onSegment(a, b, c)) return true;
if (o2 == 0 && onSegment(a, b, d)) return true;
if (o3 == 0 && onSegment(c, d, a)) return true;
if (o4 == 0 && onSegment(c, d, b)) return true;
return false;
}
/**
* 计算两条线段的交点(若相交)
* @return 交点坐标,若不相交返回null
*/
public static Point getIntersection(Stick s1, Stick s2) {
if (!segmentsIntersect(s1, s2)) return null;
Point a = s1.p1, b = s1.p2;
Point c = s2.p1, d = s2.p2;
double denom = (a.x - b.x) * (c.y - d.y) - (a.y - b.y) * (c.x - d.x);
if (Math.abs(denom) < 1e-10) return null; // 平行
double px = ((a.x * b.y - a.y * b.x) * (c.x - d.x)
- (a.x - b.x) * (c.x * d.y - c.y * d.x)) / denom;
double py = ((a.x * b.y - a.y * b.x) * (c.y - d.y)
- (a.y - b.y) * (c.x * d.y - c.y * d.x)) / denom;
return new Point(px, py);
}
}
3. 挑棍游戏核心逻辑
import java.util.*;
/**
* 挑棍游戏引擎:管理棍子状态与抽取策略
*/
public class PickUpSticksGame {
private final List<Stick> sticks; // 所有棍子
private final boolean[] removed; // 是否已被移除
private final int[][] intersectionCount; // 相交计数矩阵
public PickUpSticksGame(List<Stick> sticks) {
this.sticks = new ArrayList<>(sticks);
this.removed = new boolean[sticks.size()];
this.intersectionCount = new int[sticks.size()][sticks.size()];
computeIntersections();
}
/** 预计算所有棍子之间的相交关系 */
private void computeIntersections() {
int n = sticks.size();
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (GeometryEngine.segmentsIntersect(sticks.get(i), sticks.get(j))) {
intersectionCount[i][j] = 1;
intersectionCount[j][i] = 1;
}
}
}
}
/**
* 判断一根棍子是否可以安全抽取
* 条件:该棍子没有被其他未移除的棍子压住(即不与任何未移除棍子相交)
*/
public boolean canPick(int stickIndex) {
if (removed[stickIndex]) return false;
for (int i = 0; i < sticks.size(); i++) {
if (i != stickIndex && !removed[i] && intersectionCount[stickIndex][i] > 0) {
return false;
}
}
return true;
}
/**
* 获取所有可安全抽取的棍子索引
*/
public List<Integer> getPickableSticks() {
List<Integer> result = new ArrayList<>();
for (int i = 0; i < sticks.size(); i++) {
if (canPick(i)) {
result.add(i);
}
}
return result;
}
/**
* 获取指定棍子当前被多少根未移除的棍子压住
*/
public int getPressureCount(int stickIndex) {
int count = 0;
for (int i = 0; i < sticks.size(); i++) {
if (i != stickIndex && !removed[i] && intersectionCount[stickIndex][i] > 0) {
count++;
}
}
return count;
}
/**
* 贪心策略:选择被压最少的可抽取棍子
* @return 最优棍子索引,无可抽取则返回 -1
*/
public int greedyPick() {
List<Integer> pickable = getPickableSticks();
if (pickable.isEmpty()) return -1;
// 在可抽取的棍子中,选择被压最少的(最安全的)
int best = pickable.get(0);
int minPressure = Integer.MAX_VALUE;
for (int idx : pickable) {
int pressure = getPressureCount(idx);
if (pressure < minPressure) {
minPressure = pressure;
best = idx;
}
}
return best;
}
/**
* 执行抽取操作
* @return true 表示抽取成功
*/
public boolean pick(int stickIndex) {
if (!canPick(stickIndex)) return false;
removed[stickIndex] = true;
return true;
}
/**
* 检查游戏是否结束(所有棍子被移除或无法继续)
*/
public boolean isGameOver() {
return getPickableSticks().isEmpty();
}
/**
* 获取剩余棍子数量
*/
public int getRemainingCount() {
int count = 0;
for (boolean r : removed) {
if (!r) count++;
}
return count;
}
/**
* 打印当前游戏状态
*/
public void printState() {
System.out.println("=== 当前游戏状态 ===");
for (int i = 0; i < sticks.size(); i++) {
if (!removed[i]) {
int pressure = getPressureCount(i);
boolean pickable = canPick(i);
System.out.printf(" %s | 被压: %d | 可抽: %s%n",
sticks.get(i), pressure, pickable ? "是" : "否");
}
}
System.out.printf("剩余棍子: %d%n%n", getRemainingCount());
}
}
4. 主程序演示
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* 挑棍游戏主程序
*/
public class PickUpSticksDemo {
public static void main(String[] args) {
Random random = new Random(42);
int n = 12; // 12根棍子
List<Stick> sticks = new ArrayList<>();
String[] colors = {"红", "蓝", "绿", "黄", "紫", "橙"};
// 随机生成棍子
System.out.println("=== 生成随机棍子 ===");
for (int i = 0; i < n; i++) {
double x1 = random.nextDouble() * 10;
double y1 = random.nextDouble() * 10;
double angle = random.nextDouble() * Math.PI;
double length = 2.0 + random.nextDouble() * 3.0;
double x2 = x1 + length * Math.cos(angle);
double y2 = y1 + length * Math.sin(angle);
sticks.add(new Stick(i, new Point(x1, y1), new Point(x2, y2), colors[i % colors.length]));
System.out.println(" " + sticks.get(i));
}
PickUpSticksGame game = new PickUpSticksGame(sticks);
// 预计算相交关系
System.out.println("\n=== 相交关系矩阵 ===");
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (GeometryEngine.segmentsIntersect(sticks.get(i), sticks.get(j))) {
Point inter = GeometryEngine.getIntersection(sticks.get(i), sticks.get(j));
System.out.printf(" Stick%d 与 Stick%d 相交于 %s%n", i, j, inter);
}
}
}
System.out.println("\n=== 开始游戏(贪心策略) ===");
game.printState();
int round = 1;
while (!game.isGameOver()) {
int pick = game.greedyPick();
if (pick < 0) break;
Stick s = sticks.get(pick);
System.out.printf("第%d轮: 抽取 %s%n", round, s);
game.pick(pick);
game.printState();
round++;
}
System.out.println("=== 游戏结束 ===");
int remaining = game.getRemainingCount();
System.out.printf("共进行 %d 轮,剩余 %d 根棍子无法抽取%n", round - 1, remaining);
if (remaining == 0) {
System.out.println("恭喜!成功移除所有棍子!");
} else {
System.out.println("部分棍子被锁住,需要更巧妙的策略。");
}
}
}
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 相交检测(两线段) | $O(1)$ | $O(1)$ | 常数次算术运算 |
| 预计算所有相交 | $O(n^2)$ | $O(n^2)$ | $n$ 为棍子数量 |
| 判断单根可抽取 | $O(n)$ | $O(1)$ | 遍历所有其他棍子 |
| 获取所有可抽取 | $O(n^2)$ | $O(n)$ | 对每根棍子做判断 |
| 贪心选择 | $O(n^2)$ | $O(n)$ | 先找可抽取,再算压力 |
扩展思考
- Z-Order排序优化:按棍子的Z坐标(高度)排序后,只需检测相邻层级的相交关系,可将预处理降至 $O(n \log n)$(在均匀分布假设下)。
- 回溯策略改进:贪心策略在局部最优时可能陷入全局死局。引入回溯搜索,在每一步尝试所有可行选择,通过DFS寻找能清空所有棍子的最优序列。
- 物理模拟增强:加入弹性形变和摩擦力模拟,使”触碰”判断更贴近真实物理场景。
总结
本文通过Java实现了挑棍游戏的完整模拟系统,核心要点包括:
- 跨立实验利用向量叉积符号判断线段相交,是计算几何的基础工具
- 相交矩阵预处理所有棍子间的空间关系,将运行时查询优化至 $O(1)$
- 贪心抽取策略优先选择被压最少的棍子,在局部最优与操作可行性之间取得平衡
挑棍将几何判断与策略选择融为一体,是理解计算几何和贪心算法的趣味案例。通过扩展回溯搜索,还能进一步探索组合优化的奥秘。