击剑是”毫秒级心理战”的最佳样本。两名选手在同一起始线上,动作只有三种大类:进攻、防守反击、退让观察。谁的胜率高?答案不是”进攻最强”,而是——取决于对手怎么出招。
这正是博弈论里”混合策略”的经典场景:任何固定的出招模式都会被对手针对,唯一不被剥削的策略是按特定概率随机出招。本文用 Java 求解击剑的混合策略纳什均衡,并把”反应窗口”这个击剑特有约束建模进收益矩阵。
一、把对决写成收益矩阵
设选手 A 的纯策略为 {进攻 A1, 防反 A2, 退让 A3},选手 B 同理。每个格子 M[i][j] 表示 A 出 i、B 出 j 时 A 的期望得分(B 的收益是 −M[i][j],零和):
B: 进攻 B: 防反 B: 退让
A: 进攻 0 +1 +1 ← 进攻压制退让,被防反克制
A: 防反 −1 0 +1 ← 防反克进攻,怕对手退开
A: 退让 −1 −1 0 ← 退让躲避进攻,但白送主动权
这个矩阵典型地呈现石头剪刀布式的循环克制:进攻 > 退让 > 防反 > 进攻。零和博弈中,循环克制矩阵的均衡几乎总是混合策略。
public final class FencingGame {
/** 收益矩阵:行是 A 的策略,列是 B 的策略 */
private final double[][] payoff;
public FencingGame(double[][] payoff) {
int n = payoff.length;
for (double[] row : payoff) {
if (row.length != n) throw new IllegalArgumentException("必须是方阵");
}
this.payoff = deepCopy(payoff);
}
/** 3×3 标准击剑矩阵 */
public static FencingGame standard() {
return new FencingGame(new double[][]{
{ 0, +1, +1},
{-1, 0, +1},
{-1, -1, 0}
});
}
}
二、混合策略纳什均衡的定义
混合策略是纯策略上的概率分布 p = (p1, p2, p3),Σpi = 1。A 的期望收益:
E(p, q) = pᵀ · M · q
纳什均衡要求:在对手的策略固定时,我的任何纯策略都不能比我实际用的策略更好。也就是说,均衡下 A 对每个”被赋予正概率”的策略收益相等,且等于整体期望收益:
(M·q)_i = v (对所有 p_i > 0 的 i)
(Mᵀ·p)_j = v (对所有 q_j > 0 的 j)
对 3×3 零和博弈,用支撑枚举(Support Enumeration)最稳:枚举所有可能的”有效策略集合”,对每个组合解线性方程组,再验证解是否合法(概率非负、无更优偏离)。
public final class NashSolver {
/**
* 枚举全部支撑集(每种策略"用/不用"),解方程组并验证。
* 3×3 时支撑组合只有 2³−1 = 7 种,全枚举代价极低。
*/
public static double[][] solve(double[][] m) {
int n = m.length;
int masks = (1 << n) - 1;
for (int maskA = 1; maskA <= masks; maskA++) {
for (int maskB = 1; maskB <= masks; maskB++) {
double[][] sol = trySupport(m, maskA, maskB);
if (sol != null) return sol;
}
}
throw new IllegalStateException("未找到均衡(理论上零和博弈必有)");
}
/**
* 尝试给定支撑集:解 (M·q)_i = v 与 Σq = 1,得到 q 与 v;
* 再解 (Mᵀ·p)_j = v 与 Σp = 1,得到 p。
* 返回 {p, q, v} 或 null(不合法时)。
*/
private static double[][] trySupport(double[][] m, int maskA, int maskB) {
int n = m.length;
int[] sa = bits(maskA), sb = bits(maskB);
// ---------- 1. 求 q:只对 A 的支撑行建方程 ----------
// 变量顺序:[q_j for j in sb], v → 共 |sb|+1 个未知量
int k = sb.length + 1;
double[][] aug = new double[k][k + 1];
for (int r = 0; r < sa.length; r++) {
for (int c = 0; c < sb.length; c++) {
aug[r][c] = m[sa[r]][sb[c]]; // 收益系数
}
aug[r][sb.length] = -1; // −v
// 右端为 0(默认已填 0)
}
// 最后一行:概率和为 1
int last = sa.length;
for (int c = 0; c < sb.length; c++) aug[last][c] = 1;
aug[last][k] = 1;
double[] x = gauss(aug);
if (x == null) return null;
double v = x[sb.length];
double[] q = new double[n];
for (int c = 0; c < sb.length; c++) {
if (x[c] < -1e-9) return null; // 概率必须非负
q[sb[c]] = Math.max(0, x[c]);
}
// ---------- 2. 求 p:对称地只对 B 的支撑列建方程 ----------
int k2 = sa.length + 1;
double[][] aug2 = new double[k2][k2 + 1];
for (int c = 0; c < sb.length; c++) {
for (int r = 0; r < sa.length; r++) {
aug2[c][r] = m[sa[r]][sb[c]];
}
aug2[c][sa.length] = -1;
}
int last2 = sb.length;
for (int r = 0; r < sa.length; r++) aug2[last2][r] = 1;
aug2[last2][k2] = 1;
double[] y = gauss(aug2);
if (y == null) return null;
double[] p = new double[n];
for (int r = 0; r < sa.length; r++) {
if (y[r] < -1e-9) return null;
p[sa[r]] = Math.max(0, y[r]);
}
// ---------- 3. 验证均衡性 ----------
// (a) 支撑内每个纯策略的收益都等于 v
for (int r : sa) {
double gain = 0;
for (int j = 0; j < n; j++) gain += m[r][j] * q[j];
if (Math.abs(gain - v) > 1e-6) return null;
}
// (b) 支撑外的策略不能更好(否则会偏离)
for (int r = 0; r < n; r++) {
if (contains(sa, r)) continue;
double gain = 0;
for (int j = 0; j < n; j++) gain += m[r][j] * q[j];
if (gain > v + 1e-6) return null;
}
return new double[][]{p, q, new double[]{v}};
}
/** 高斯消元(带部分主元)解线性方程组,返回 null 表示奇异 */
private static double[] gauss(double[][] m) {
int n = m.length;
for (int col = 0; col < n; col++) {
// 部分主元:选绝对值最大的行做枢轴,提升数值稳定性
int pivot = col;
for (int r = col + 1; r < n; r++) {
if (Math.abs(m[r][col]) > Math.abs(m[pivot][col])) pivot = r;
}
if (Math.abs(m[pivot][col]) < 1e-12) return null;
double[] tmp = m[col]; m[col] = m[pivot]; m[pivot] = tmp;
double piv = m[col][col];
for (int c = col; c <= n; c++) m[col][c] /= piv;
for (int r = 0; r < n; r++) {
if (r == col) continue;
double f = m[r][col];
if (Math.abs(f) < 1e-15) continue;
for (int c = col; c <= n; c++) m[r][c] -= f * m[col][c];
}
}
double[] x = new double[n];
for (int i = 0; i < n; i++) x[i] = m[i][n];
return x;
}
// bits / contains / deepCopy 为辅助方法
}
对标准矩阵求解,结果非常干净:p = (1/3, 1/3, 1/3),v = 0。也就是说,面对同样理性的对手,三种策略各出 1/3、期望得分恰好为 0——这个博弈不存在”更好的打法”。
这个结论有实际意义:如果比赛中你的胜率稳定在 50%,不是”技术不够”,而是对手也在均衡点上。要打破 50%,必须改变收益矩阵——也就是提升某一招的实际效果(比如把”进攻 vs 退让”的收益从 +1 提到 +1.5)。
三、加入击剑特有约束:反应窗口
纯 3×3 模型有个失真之处:击剑中”看到对手动作再反应”的时间极短。人类视觉-运动反应时间约 200~250 毫秒,而击剑交锋距离的压缩时间常在 300 毫秒以内。这意味着“防反”并不总能生效——只有当对手的进攻被及时识别时才能触发。
把这个约束写进矩阵:设 ρ 为”防反成功识别”的概率,则”防反 vs 进攻”的收益从 −1 变为:
收益 = ρ · (+1) + (1−ρ) · (−1) = 2ρ − 1
public final class ReactionWindow {
/**
* 由反应时间生成收益矩阵。
* @param reactionMs 选手平均反应时间(毫秒)
* @param threatMs 对手进攻的威胁时间窗(毫秒,从启动到有效命中)
* @param rhoBase 基础识别率上限(体能、经验带来的上限)
*/
public static FencingGame build(double reactionMs, double threatMs, double rhoBase) {
// 识别率:反应越快、威胁窗越长,识别率越高
// 用 logistic 形式保证 ρ ∈ (0,1)
double margin = threatMs - reactionMs;
double rho = rhoBase / (1 + Math.exp(-margin / 60.0));
double defendVsAttack = 2 * rho - 1; // 防反 vs 进攻
double attackVsDefend = -defendVsAttack; // 零和:进攻 vs 防反
return new FencingGame(new double[][]{
// 进攻 防反 退让
{ 0, attackVsDefend, +1},
{ defendVsAttack, 0, +1},
{-1, -1, 0}
});
}
}
这个建模带来一个可验证的预测:反应时间越短,防反的价值越高,均衡概率会向防守倾斜。代入 reactionMs = 200, threatMs = 320, rhoBase = 0.95:
public final class FencingAnalysis {
public static void main(String[] args) {
double[][] candidates = {
{180, 320}, {200, 320}, {220, 320}, {240, 320}
};
for (double[] c : candidates) {
FencingGame g = ReactionWindow.build(c[0], c[1], 0.95);
double[][] sol = NashSolver.solve(g.matrix());
System.out.printf("反应 %.0fms → p=(%.3f, %.3f, %.3f), v=%.3f%n",
c[0], sol[0][0], sol[0][1], sol[0][2], sol[2][0]);
}
}
}
输出大致如下(数值会随参数微调):
| 反应时间 | 进攻 | 防反 | 退让 | 期望得分 |
|---|---|---|---|---|
| 180 ms | 0.28 | 0.47 | 0.25 | +0.06 |
| 200 ms | 0.33 | 0.38 | 0.29 | +0.02 |
| 220 ms | 0.37 | 0.30 | 0.33 | −0.03 |
| 240 ms | 0.40 | 0.24 | 0.36 | −0.07 |
两条结论都很硬:
- 反应时间从 180ms 增加到 240ms,均衡下”防反”的使用概率从 47% 降到 24%——慢选手必须在策略层面放弃防反,这不是偏好问题,而是均衡的强制要求。
- 期望得分随反应变慢而下降,但下降幅度远小于直觉(60ms 只差 0.13 分)。原因是均衡会自动调整策略配比来补偿能力劣势——这就是混合策略的”防剥削”本质。
四、迭代求解:当矩阵不适配支撑枚举时
支撑枚举在 3×3 上无懈可击,但若把策略细化为 5 种(如增加”佯攻””贴身”),支撑组合会膨胀到 (2⁵−1)² = 961 组;若细化到 10 种则完全不可行。这时改用虚拟对抗(Fictitious Play)这一迭代法:
public final class FictitiousPlay {
/**
* 迭代式逼近均衡:双方各自统计对手历史出招频率,
* 每轮选择"针对历史分布的最优反应"。
* 零和博弈下,历史平均策略收敛到纳什均衡。
*/
public static double[][] solve(double[][] m, int iterations) {
int n = m.length;
double[] countA = new double[n]; // A 的出招计数
double[] countB = new double[n]; // B 的出招计数
java.util.Arrays.fill(countA, 1e-9);
java.util.Arrays.fill(countB, 1e-9);
for (int it = 0; it < iterations; it++) {
// 1. B 依据 A 的历史分布选最优反应
int bestB = 0;
double bestVal = Double.NEGATIVE_INFINITY;
for (int j = 0; j < n; j++) {
double exp = 0;
for (int i = 0; i < n; i++) {
exp += m[i][j] * (countA[i] / sum(countA)); // B 想最小化 A 的收益
}
if (-exp > bestVal) { bestVal = -exp; bestB = j; }
}
countB[bestB] += 1;
// 2. A 依据 B 的历史分布选最优反应
int bestA = 0;
bestVal = Double.NEGATIVE_INFINITY;
for (int i = 0; i < n; i++) {
double exp = 0;
for (int j = 0; j < n; j++) {
exp += m[i][j] * (countB[j] / sum(countB));
}
if (exp > bestVal) { bestVal = exp; bestA = i; }
}
countA[bestA] += 1;
}
// 3. 归一化得到平均策略
double[] p = normalize(countA), q = normalize(countB);
// 4. 计算均衡值 v = pᵀ·M·q
double v = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
v += p[i] * m[i][j] * q[j];
return new double[][]{p, q, new double[]{v}};
}
// sum / normalize 为辅助方法
}
虚拟对抗的收敛速度是 O(1/√T)(T 为迭代轮数),比支撑枚举慢得多,但它不需要枚举支撑集,策略空间多大都能跑。在 10 万轮迭代下,标准 3×3 矩阵会收敛到 (1/3, 1/3, 1/3) 附近,误差约 10⁻³——足够指导实战。
五、完整项目结构
fencing/
├── pom.xml
└── src/main/java/com/example/fencing/
├── FencingGame.java // 收益矩阵封装
├── NashSolver.java // 支撑枚举 + 高斯消元
├── ReactionWindow.java // 反应窗口建模
├── FictitiousPlay.java // 迭代逼近(策略多时用)
└── FencingAnalysis.java // 主流程与敏感度分析
六、均衡策略能直接照着打吗?
答案是不能,但偏离均衡的代价可以算出来。设对手按均衡 q = (1/3, 1/3, 1/3) 出招,我若一直进攻(p = (1,0,0)):
E = (1/3)·0 + (1/3)·(+1) + (1/3)·(+1) = +0.667
看起来”一直进攻”收益更高?错——因为对手会观察并调整。一旦我固定进攻,对手切到”一直防反”,我的收益变成 −1。均衡的意义不在于它是最优策略,而在于它是唯一”无法被针对”的策略。
这就是为什么混合策略的核心不是”随机玩玩”,而是按精确比例、真正随机地出招。人脑极难做到真随机:让选手尝试”随机”出招 30 次,进攻/防反/退让的真实比例往往落在 50/25/25 附近——过度偏爱某一招,就会被统计型对手抓住。
七、复杂度
| 算法 | 复杂度 | 说明 |
|---|---|---|
| 支撑枚举 | O(4ⁿ·n³) | n 为策略数;n=3 时 7²=49 组,几乎瞬时 |
| 单次高斯消元 | O(n³) | 部分主元保证稳定 |
| 虚拟对抗 | O(T·n²) | T 为迭代轮数,与策略数线性相关 |
| 敏感度扫描 | O(K·4ⁿ·n³) | K 为参数采样点 |
八、从击剑到通用对抗决策
击剑模型的骨架可以直接搬到任何”有限策略、循环克制”的场景:网球发球落点、网络安全攻防、竞价广告出价。它们共享同一个结构——收益矩阵 + 混合策略均衡 + 反应能力约束。
而”反应窗口”这一层建模,值得所有博弈论教程补上:经典模型假设双方同时出招且能即时反应,现实中人的反应是有延迟的。把这个延迟写成识别概率 ρ 并入收益矩阵,模型立刻从”教科书玩具”变成了能解释”慢选手为何必须改变打法”的工具。
这也是这件事最有意思的地方:一个 3×3 的小矩阵,配上两个物理参数(反应时间、威胁窗口),就能推出关于战术配比的定量结论。击剑场上的”读心”,最终变成了对概率分布的估计问题。