从石头剪刀布到桌游:用博弈论破解《惨剧轮回》

从石头剪刀布谈起

Alice 和 Bob 在玩石头剪刀布。站在 Alice 的视角,将获胜记为 \(1\),平局记为 \(0\),失败记为 \(-1\),可以得到收益矩阵

\[M= \begin{pmatrix} 0&1&-1\\ -1&0&1\\ 1&-1&0 \end{pmatrix}. \]

矩阵的三行分别表示 Alice 出石头、剪刀、布,三列分别表示 Bob 出石头、剪刀、布。因此,这个矩阵本身就可以理解为一局游戏:Alice 选择一行,Bob 选择一列,行列交点处的数值就是这一局的结果。

例如 Alice 选择第一行,也就是出石头;如果 Bob 选择第二列,也就是出剪刀,那么交点为 \(1\),Alice 获胜;如果 Bob 选择第三列,也就是出布,那么交点为 \(-1\),Alice 失败。

显然,双方会按照某种概率随机行动。设 Alice 选择三行的概率为 \(\sigma=(\sigma_1,\sigma_2,\sigma_3)\),Bob 选择三列的概率为 \(\tau=(\tau_1,\tau_2,\tau_3)\)。这样每进行一局游戏,就相当于 Alice 按照 \(\sigma\) 随机选择一行,Bob 按照 \(\tau\) 随机选择一列,再看交点处的结果。长期重复游戏后,Alice 的平均收益为 \(\sigma^\top M\tau\)

那么 Alice 应该选择怎样的概率分布呢?假设 Alice 永远出石头,即 \(\sigma=(1,0,0)\),那么 Bob 只要永远出布,就可以让 Alice 每局都得到 \(-1\)。即使 Alice 不是固定出某一种,只要她的策略存在明显偏向,Bob 也可以根据这种偏向调整自己的策略。

要知道 Bob 不是傻子,如果 Bob 已经观察到 Alice 使用的策略,肯定会专门选择对 Alice 最不利的策略。因此,评价一个 Alice 的策略 \(\sigma\) 时,我们要考虑的是,在被针对的情况下 Alice 最多还能保证多少收益?

对于固定的 \(\sigma\),Bob 所能造成的最坏结果为 \(\min_\tau \sigma^\top M\tau\)。这个值表示 Alice 使用策略 \(\sigma\) 时,无论 Bob 怎样针对自己,都至少能够保证的收益。不同的 \(\sigma\) 能保证的最低收益不同,因此 Alice 真正需要做的是从所有可能的策略中,选择一个“最坏情况下仍然最好”的策略:

\[\max_\sigma\min_\tau \sigma^\top M\tau. \]

这就是 minimax 的含义。其中内层的 \(\min_\tau\) 表示先考虑 Bob 对当前策略最不利的应对,外层的 \(\max_\sigma\) 表示 Alice 再选择一个策略,使这种最坏情况下的结果尽可能好。

注意,这里并不是说实际游戏中 Alice 先公布策略、Bob 再行动;双方仍然是同时行动。这个式子表达的是:一个策略如果要称为最优,就应当在对手知道并尽力针对它的情况下仍然成立。

石头剪刀布的这个矩阵博弈可以通过线性规划(Linear Programming, LP)精确求解,得到双方的最优策略均为 \((1/3,1/3,1/3)\),即分别以 \(1/3\) 的概率随机选择石头、剪刀和布。此时任何一方都无法通过改变自己的策略获得额外优势,博弈的期望收益为 \(0\)

游戏规则

前几天,平时一起玩桌游《惨剧轮回》的朋友向我问了这样一个问题:在“谋杀计划”中,如果双方都采取最优策略,剧作家有多大概率获胜,以及应该如何出牌?

本文只考虑与这个问题相关的游戏规则,并等价变换为更简洁的形式。剧作家记为 Alice,主人公记为 Bob。Alice 的目标是通过移动角色和增加密谋满足胜利条件,Bob 则需要阻止 Alice,由于无法临时变牌,我们可以认为双方在每个回合都会同时对角色出牌。

游戏设定

版图由医院、神社、都市、学校 4 个区域组成,排列为一个 \(2\times2\) 方格。角色可以进行左右、上下或斜向移动。

“谋杀计划”追加关键人物、主谋和杀手各 1 名。本文假设这 3 名角色的初始区域随机,并且游戏开始时 Alice 与 Bob 都明确知道三名角色的身份。

双方手牌

Alice 的手牌包括:1 张“左右移动”、1 张“上下移动”、1 张“斜向移动”(每张限使用一次)、1 张“密谋+1”、1 张“密谋+2”(每张限使用一次)和 3 张空白牌。

Bob 的手牌包括:3 张“左右移动”、3 张“上下移动”、3 张“禁止移动”(每张限使用一次)、1 张“禁止密谋”和 3 张空白牌。

双方每回合都必须分别对关键人物、主谋和杀手各出 1 张牌。游戏共有 \(D\) 个回合。

回合流程

每个回合依次进行以下结算:

  1. Alice 与 Bob 同时对三名角色各出 1 张牌。出牌时,双方都不知道对方本回合打出的具体牌名。
  2. 结算密谋牌。如果角色被打出“密谋+1”或“密谋+2”,且没有被打出“禁止密谋”,则该角色增加对应数量的密谋。
  3. 结算移动牌。如果角色被打出移动牌,且没有被打出“禁止移动”,则按照对应方向移动。若同一角色被打出两张牌名不同的移动牌,则先合成移动方向:左右+上下=斜向,左右+斜向=上下,上下+斜向=左右。
  4. 公开并弃置本回合打出的“限使用一次”的牌。此时双方都可以得知这些牌。
  5. 主谋选择同一区域的 1 名角色,使其密谋+1。

胜负判定

每个回合结算后,如果满足以下任意一个条件,Alice 立即获胜:杀手的密谋达到 4;或杀手与关键人物处于同一区域,且关键人物的密谋达到 2。

如果 \(D\) 个回合全部结束后 Alice 仍未获胜,则 Bob 获胜。

问题建模

进行到任意一个回合时,我们只关心那些影响之后胜负的信息,因此我们先考虑如何表示状态。

状态表示

设 Key Person、Brain、Killer 分别记为 \(P,B,K\)。四个区域记为 \(\mathbb F_2^2=\{0,1\}^2\),左右、上下、斜向三种移动分别记为 \(e_x=(1,0)\)\(e_y=(0,1)\)\(e_d=(1,1)\)

我们只关心角色的相对位置,因此以 Key Person 为原点,令 \(x,y\in\mathbb F_2^2\) 分别表示 Brain、Killer 相对于 Key Person 的位置。这样 \(x=0\) 表示 Brain 与 Key Person 同区,\(y=0\) 表示 Killer 与 Key Person 同区,而 \(x=y\) 表示 Brain 与 Killer 同区。

state_representation
图 1:角色的相对位置状态图

\(c\in\{0,1,2,3,4\}\)\(h\in\{0,1,2\}\) 分别表示 Killer 与 Key Person 的截断密谋值,因为一旦达到对应阈值,更大的数值不会改变之后的胜负判断。再令 \(r\) 表示双方剩余的一次性牌。

于是,一个局面可以表示为

\[S=(x,y,c,h,r). \]

剩余回合数 \(d\) 会在后面的价值函数 \(V_d(S)\) 里单独记录。

回合行动

在状态 \(S\) 下,Alice 与 Bob 的合法完整行动集合分别记为 \(\mathcal A(S)\)\(\mathcal B(S)\)。一个完整行动指这一回合对三名角色的三张牌一起组成的出牌方案:

\[\mathbf a=(a_P,a_B,a_K)\in\mathcal A(S),\qquad \mathbf b=(b_P,b_B,b_K)\in\mathcal B(S). \]

双方同时选择 \(\mathbf a\)\(\mathbf b\),随后按照游戏规则依次结算。

\(i\in\{P,B,K\}\),令 \(u_i\in\{0,e_x,e_y,e_d\}\)\(v_i\in\{0,e_x,e_y\}\) 分别表示 Alice、Bob 对角色 \(i\) 产生的移动类型,其中 0 表示没有移动牌。

两张移动牌同时作用在同一角色上时,移动合成为

\[u\circ v=\begin{cases} u,&u=v,\\ u+v,&u\ne v, \end{cases} \]

其中加法在 \(\mathbb F_2^2\) 上进行。

再令 \(n_i\in\{0,1\}\) 表示 Bob 是否对角色 \(i\) 使用禁止移动,并记实际位移为 \(m_i=(1-n_i)(u_i\circ v_i)\)。考虑到 Key Person 一旦移动,Brain 和 Killer 相对于 Key Person 的位置都会一起改变。结算移动后,两个相对位置变为

\[x'=x+m_B+m_P,\qquad y'=y+m_K+m_P. \]

\(q_i\in\{0,1,2\}\) 表示 Alice 对角色 \(i\) 打出的密谋增量,\(f_i\in\{0,1\}\) 表示 Bob 是否对角色 \(i\) 使用禁止密谋。

移动和密谋牌结算后,Alice 还需要决定 Brain 的能力目标。令 \(\alpha,\beta\in\{0,1\}\) 分别表示 Brain 是否给 Killer、Key Person 增加 1 点密谋。由于 Brain 只能选择同一区域的角色,并且一次只能选择一个目标,合法选择集合为

\[C(S,\mathbf a,\mathbf b)=\left\{(\alpha,\beta)\in\{0,1\}^2:\alpha+\beta\le1,\ \alpha\le\mathbf 1_{\{x'=y'\}},\ \beta\le\mathbf 1_{\{x'=0\}}\right\}. \]

于是本回合结算后的密谋值为

\[c'=\min\{4,c+(1-f_K)q_K+\alpha\},\qquad h'=\min\{2,h+(1-f_P)q_P+\beta\}. \]

本回合使用的一次性牌也会从剩余手牌中移除。若用 \(\rho(r,\mathbf a,\mathbf b)\) 表示这一更新,那么完整的状态转移可以写成

\[\Phi(S,\mathbf a,\mathbf b,\alpha,\beta) =(x',y',c',h',\rho(r,\mathbf a,\mathbf b)). \]

即通过当前状态、双方这一回合的完整出牌,以及 Brain 最后的选择,就可以下一状态。

胜利条件

Alice 有两种获胜方式:Killer 的密谋达到 4;或 Killer 与 Key Person 同区域,并且 Key Person 的密谋达到 2。因此把所有已经满足胜利条件的状态记为

\[W=\{(x,y,c,h,r):c=4\}\cup\{(x,y,c,h,r):y=0,\ h=2\}. \]

到这里,原来的游戏规则已经被整理成了三部分:当前状态 \(S\)、本回合状态转移 \(\Phi\),以及胜利状态集合 \(W\)。后续的动态规划在三者上进行。

价值函数与最优策略

石头剪刀布每一局是独立的,所以行列交点可以直接写成 \(1,0,-1\)。而在这里,双方当前回合的出牌通常不会立刻决定整局游戏,而是把游戏带到一个新的状态。因此,一个行动组合的收益为,进入这个新状态以后,Alice 最终还能以多大概率获胜。

因此,总体思路是,先解决只剩较少回合的情况,再用这些结果去计算还剩更多回合的情况。

状态价值

\(d\in\{0,1,\ldots,D\}\) 表示当前剩余回合数。定义 \(V_d(S)\) 为:当前处于状态 \(S\),还剩 \(d\) 个回合,并且之后双方都采取最优策略时,Alice 最终获胜的概率。

可以把 \(V_d(S)\) 直接理解成这个局面对 Alice 的价值。例如 \(V_d(S)=0.8\),表示从这个局面继续最优对抗,Alice 最终有 80% 的获胜概率。

\(d=0\) 时已经没有任何回合可以继续。如果此时已经满足 Alice 的胜利条件,则结果为 1,否则为 0,因此

\[V_0(S)=\mathbf 1_{\{S\in W\}}. \]

现在考虑 \(d\ge1\)。假设当前双方完整行动 \(\mathbf a,\mathbf b\) 已经确定并公开,本回合只剩 Brain 的能力目标需要由 Alice 选择。

不同的 Brain 选择会产生不同的下一状态。Alice 自然会选择其中后续胜率最高的一个,因此定义

\[Q_d(S,\mathbf a,\mathbf b) =\max_{(\alpha,\beta)\in C(S,\mathbf a,\mathbf b)} V_{d-1}\!\left(\Phi(S,\mathbf a,\mathbf b,\alpha,\beta)\right). \]

这表示,如果本回合双方已经确定采用这对出牌方案,那么 Alice 在最优选择 Brain 目标之后,最终能够获得的胜率是多少。

双方目标

真正出牌时,Alice 并不知道 Bob 会选择哪个完整行动,Bob 也不知道 Alice 的选择,所以双方都需要采取某种概率分布的混合策略。

对任意有限集合 \(X\),记 \(\Delta(X)\)\(X\) 上所有概率分布的集合。令 Alice、Bob 的混合策略分别为 \(\sigma\in\Delta(\mathcal A(S))\)\(\tau\in\Delta(\mathcal B(S))\)。若 \(S\in W\),显然 \(V_d(S)=1\);否则

\[V_d(S)=\max_{\sigma\in\Delta(\mathcal A(S))}\min_{\tau\in\Delta(\mathcal B(S))} \sum_{\mathbf a\in\mathcal A(S)}\sum_{\mathbf b\in\mathcal B(S)} \sigma(\mathbf a)\tau(\mathbf b)Q_d(S,\mathbf a,\mathbf b). \]

这个式子和前面石头剪刀布的 minimax 完全是同一件事:对 Alice 的一个随机出牌方案,先考虑 Bob 最不利的应对,再从所有随机方案中选择最能保证胜率的那个。

对应的最优局部出牌策略为

\[\sigma_d^*(S)\in\arg\max_{\sigma\in\Delta(\mathcal A(S))}\min_{\tau\in\Delta(\mathcal B(S))} \sum_{\mathbf a,\mathbf b}\sigma(\mathbf a)\tau(\mathbf b)Q_d(S,\mathbf a,\mathbf b), \]

\[\tau_d^*(S)\in\arg\min_{\tau\in\Delta(\mathcal B(S))}\max_{\sigma\in\Delta(\mathcal A(S))} \sum_{\mathbf a,\mathbf b}\sigma(\mathbf a)\tau(\mathbf b)Q_d(S,\mathbf a,\mathbf b). \]

这里的 \(\sigma_d^*(S)\)\(\tau_d^*(S)\) 是对所有合法完整出牌方案给出的概率分布。

初始胜率

游戏开始时,三名角色的绝对位置独立均匀随机。换成相对位置后,有

\[x,y\overset{\mathrm{i.i.d.}}{\sim}\operatorname{Unif}(\mathbb F_2^2),\qquad c=h=0. \]

若初始剩余手牌状态记为 \(r_0\),则开始时剩余 \(D\) 个回合。对 16 种可能的初始相对位置取平均,就得到 Alice 的总体最优胜率

\[V^*=\frac1{16}\sum_{x,y\in\mathbb F_2^2}V_D(x,y,0,0,r_0). \]

到此为止,我们的目标已经变为,求出所有需要的 \(V_d(S)\),以及对应的 \(\sigma_d^*(S)\)\(\tau_d^*(S)\)

求解方法

上一节给出了递推公式,但真正计算时我们并不需要一次处理整个游戏树。如果已经知道所有还剩 \(d-1\) 个回合的状态价值,那么对于一个还剩 \(d\) 个回合的状态,只需要解决当前这一回合的出牌问题。

所以每一层的计算都分成两步:先把当前状态变成一个收益矩阵,再求这个矩阵博弈的最优混合策略。

构造收益矩阵

固定 \(d\ge1\) 与一个尚未胜利的状态 \(S\)。把 Alice 与 Bob 所有合法的完整行动分别编号为

\[\mathcal A(S)=\{\mathbf a^{(1)},\ldots,\mathbf a^{(m)}\},\qquad \mathcal B(S)=\{\mathbf b^{(1)},\ldots,\mathbf b^{(\ell)}\}. \]

现在和石头剪刀布一样,把 Alice 的行动放在行上,把 Bob 的行动放在列上。对于第 \(p\) 行和第 \(q\) 列,双方本回合的出牌已经完全确定。结算这些牌以后,再由 Alice 选择最有利的 Brain 目标,因此定义

\[M_d(S)_{pq} =Q_d\!\left(S,\mathbf a^{(p)},\mathbf b^{(q)}\right) =\max_{(\alpha,\beta)\in C(S,\mathbf a^{(p)},\mathbf b^{(q)})} V_{d-1}\!\left(\Phi(S,\mathbf a^{(p)},\mathbf b^{(q)},\alpha,\beta)\right). \]

于是 \(M_d(S)\) 就是当前状态对应的收益矩阵。第 \(p\) 行、第 \(q\) 列的交点不再像石头剪刀布那样是 \(1,0,-1\),而是这对出牌结算以后,Alice 从下一状态继续最优游戏的最终胜率。

如果双方采用混合策略 \(\sigma=(\sigma_1,\ldots,\sigma_m)\)\(\tau=(\tau_1,\ldots,\tau_\ell)\),那么 Alice 的期望胜率为

\[\sigma^\top M_d(S)\tau =\sum_{p=1}^m\sum_{q=1}^{\ell}\sigma_p\tau_qM_d(S)_{pq}. \]

因此当前状态的价值就是这个矩阵博弈的 minimax value:

\[V_d(S)=\max_{\sigma\in\Delta(\mathcal A(S))}\min_{\tau\in\Delta(\mathcal B(S))} \sigma^\top M_d(S)\tau \]

线性规划

矩阵 \(M_d(S)\) 一旦构造完成,剩下的问题就和普通的有限零和矩阵博弈完全一样。Alice 需要找到一个概率分布 \(\sigma\),使 Bob 无论选择哪一列,Alice 都能保证尽可能高的期望胜率。

设 Alice 想保证的最低胜率为 \(v\)。那么对于 Bob 的任何针对性策略,这个期望胜率都必须至少为 \(v\),于是得到线性规划

\[\begin{aligned} \max_{\sigma,v}\quad &v\\ \mathrm{s.t.}\quad &\sum_{p=1}^m\sigma_pM_d(S)_{pq}\ge v,\qquad q=1,\ldots,\ell,\\ &\sum_{p=1}^m\sigma_p=1,\qquad \sigma_p\ge0. \end{aligned} \]

求解以后,最优目标值就是 \(V_d(S)\),最优概率向量就是 Alice 在这个状态下的最优混合策略 \(\sigma_d^*(S)\)

Bob 的问题完全对称。他希望找到 \(\tau\),使 Alice 无论选择哪一行,期望胜率都不会超过尽可能小的 \(v\)

\[\begin{aligned} \min_{\tau,v}\quad &v\\ \mathrm{s.t.}\quad &\sum_{q=1}^{\ell}M_d(S)_{pq}\tau_q\le v,\qquad p=1,\ldots,m,\\ &\sum_{q=1}^{\ell}\tau_q=1,\qquad \tau_q\ge0. \end{aligned} \]

它给出 Bob 的最优混合策略 \(\tau_d^*(S)\)。由有限零和博弈的极小极大定理,两边得到的最优值相同,所以只要解完当前矩阵,就同时知道了这个状态的胜率和双方应该怎样随机出牌。

实际计算时可以直接交给标准 LP solver,用线性规划从已经构造好的收益矩阵中找出最优随机概率。

动态规划

动态规划的边界条件是 \(V_0(S)=\mathbf 1_{\{S\in W\}}\).

\(d=1\) 时,本回合就是最后回合。每一对行动结算以后,要么满足胜利条件,要么没有满足,因此 \(M_1(S)\) 的元素只会是 0 或 1。解这些矩阵博弈,就得到所有 \(V_1(S)\)

有了 \(V_1\),就可以构造 \(M_2(S)\) 并求出 \(V_2(S)\);有了 \(V_2\),再求 \(V_3(S)\),以此类推一直到 \(V_D\)

真实游戏的剩余回合数是从 \(D\) 一路减到 0,求解时反过来,从最简单的 0 回合开始逐层向上计算:

\[V_0\longrightarrow V_1\longrightarrow V_2\longrightarrow\cdots\longrightarrow V_D. \]

对于其中任意一层,都可以把一步计算概括为

\[V_{d-1}\ \longrightarrow\ Q_d\ \longrightarrow\ M_d\ \longrightarrow\ V_d. \]

其中 \(Q_d\) 负责处理双方行动已经确定后的 Brain 最优选择,\(M_d\) 把所有行动组合整理成矩阵,最后通过矩阵博弈得到 \(V_d\)

使用策略

最终,我们得到一张随剩余回合数和当前状态变化的策略表。

实际进行游戏时,可以这样理解求解结果:

  1. 先根据当前角色相对位置、密谋值和剩余一次性牌确定状态 \(S\) 和剩余回合数 \(d\)
  2. Alice 按照 \(\sigma_d^*(S)\) 给出的概率,在所有完整出牌方案中随机选择一个;Bob 同样按照 \(\tau_d^*(S)\) 随机选择自己的完整出牌方案。
  3. 双方出牌公开并完成移动、密谋等结算后,Alice 再决定 Brain 的能力目标。此时对每一个合法选择比较下一状态的 \(V_{d-1}\),选择价值最大的一个。
  4. 进入新的状态,将剩余回合数改为 \(d-1\),然后重复同样的过程。

Brain 的这一部分策略可以写成

\[\gamma_d^*(S,\mathbf a,\mathbf b)\in\arg\max_{(\alpha,\beta)\in C(S,\mathbf a,\mathbf b)} V_{d-1}\!\left(\Phi(S,\mathbf a,\mathbf b,\alpha,\beta)\right). \]

因此,Alice 的完整最优策略包括两部分:出牌前根据 \(\sigma_d^*(S)\) 随机选择完整行动,以及出牌公开后根据 \(\gamma_d^*\) 选择 Brain 的目标;Bob 的完整最优策略则由 \(\tau_d^*(S)\) 给出。

注意,最优混合策略并不是挑概率最高的那个行动。比如求得 \(\sigma_d^*(S)=(0.2,0.5,0.3)\),那么真正的最优策略就是分别以 20%, 50%, 30% 的概率采用这三个完整行动,而不是永远选择概率为 50% 的行动。

如果某个状态下存在多个同样最优的混合策略,或者 Brain 有多个后继状态价值完全相同的选择,那么任选其中一个最优解即可。

最终,\(V_D(x,y,0,0,r_0)\) 给出每一种初始相对位置下 Alice 的最优胜率,按照初始位置分布取平均即可得到总体胜率 \(V^*\)。而 \(\sigma_d^*(S)\)\(\tau_d^*(S)\)\(\gamma_d^*\) 则告诉我们,在游戏真正进行到某个具体局面时,双方应该怎样行动。

代码实现

建模完毕后,我们就可以愉快地开始 Python 了,代码详见 murder-plan-solver

核心代码分成三个部分:model.py 定义状态、行动和求解结果;rules.py 负责单回合规则结算;solver.py 负责状态搜索、动态规划和矩阵博弈求解。而 cli.py 只负责把命令行输入转换成 State 并调用求解器。

求解流程

整个求解由 Solver.solve() 驱动。一次状态求值大致包括三个阶段:生成合法行动并结算单回合效果、读取后继状态的价值并构造收益矩阵、调用线性规划求解矩阵博弈。

首先讲一下单回合的转移规则是如何建立的。 rules.py 先通过 _alice_actions()_bob_actions() 生成当前剩余手牌下的所有合法完整行动,再由 _effect() 结算每一对行动。当然,很多不同的行动组合可能产生相同的结果,因此 _turn_data_for_inventory() 会把它们合并成同一个 _Effect,并建立这些 Effect 与收益矩阵之间的索引关系。_next_states() 会考虑行动牌的 _Effect 和 Brain 不同合法选择,从而改变当前状态。

具体求解时, _reachable_layers() 从输入状态开始逐回合展开,调用 _next_states 收集下一层状态(已经满足胜利条件的状态就不需要再继续展开了),这样就向前建立了所有可以达到的状态层然后根据前文所述的 DP 顺序,从最后一层反向求值。对每个状态,_payoff_matrix() 读取下一层已经算好的 value。它先为每种 _Effect 比较 Brain 各个候选状态的价值,再利用之前保存的关系恢复完整的 Alice × Bob 收益矩阵。随后 _matrix_game() 调用 scipy.optimize.linprog,使用 HiGHS 求出这个矩阵博弈的 value 和 Alice 的最优混合策略。当前层算出的 value 会继续供前一层使用,以此类推回到最初输入的状态。

这里说一下,一个比较关键的实现是 _Effect 这一层压缩。行动组合的数量远多于真正不同的回合结果,我们只需要为每种 Effect 计算一次后续价值,再填回收益矩阵,从而省掉大量重复计算。同一剩余手牌状态下的行动和 Effect 数据也可以通过 lru_cache 复用。

求解结果

Solver.solve() 最终返回输入状态的最优胜率和 Alice 当前的混合策略,Result.pick() 可以按照这组概率抽取一次实际出牌。

average_win_rates() 使用相同的求解流程,只是把 16 种初始相对位置同时作为起点,因此它们可以共享后续的可达状态和反向计算。

缓存与性能优化

算法层面上 DP 已经节省了不少计算量,不过仍然会随回合数迅速增长,需要想想办法优化一下。

瓶颈分析

对一个状态求值我们要做三件事:根据剩余手牌取得合法行动和回合 Effect,读取后继状态 value 并构造收益矩阵,调用 linprog 求出矩阵博弈的 value 与混合策略。

分析一下,行动生成和单回合效果只由剩余的一次性牌决定,组合数量相对较少。状态转移会被大量调用,不过其中许多输入可能只出现一两次。线性规划单次规模其实不大,但调用次数实在太多了,需要频繁启动 HiGHS。

对于第一类,rules.py 使用 lru_cache 按剩余手牌保存 _turn_data_for_inventory() 的结果。第一次遇到某种手牌状态时生成完整数据,之后所有具有相同手牌的局面直接共享。注意与前文介绍的 _Effect 压缩区分,其减少的是单个矩阵内部的重复计算,lru_cache 减少的是不同状态之间的重复生成。

第二类,状态转移有两个优化角度。

两条游戏历史只要到达相同的 State 和剩余回合数 day ,之后面对的行动策略和胜负条件就完全一致。因此,状态求值后 Solver 把 value 保存在 _values 中,_results 保存完整请求过的输入。对于完全相同的一次查询,大约可以从 9 ms 缩短到 3 µs。值得一提的是,这种按访问过程建立的缓存不需要我们事先预测哪些状态常见。

另一个角度是合并对称状态。将位置坐标交换,同时交换左右与上下行动,本质应当不变。_values 使用固定选取的 canonical state 作为索引,原状态与对称状态共用一个 value。注意,混合策略带有具体方向不能直接照搬。

第三类,我们知道,状态不同不代表收益矩阵不同。矩阵元素记录每对行动结算后的最优后续胜率,只要行动数量和这些 value 的排列完全一致,linprog 收到的就是同一个问题。我们根据矩阵形状和全部元素的字节数据计算 BLAKE2b-256 摘要,以此保存 _matrix_game() 求出的 value 与 Alice 混合策略。再次出现完全相同矩阵时直接读取结果,省去一次线性规划。矩阵形状要参与索引,用来区分元素总数相同但行列结构不同的情况。

我们对 D=3 随机初始位置的求解进行了两组插桩。共请求求解 3571 个矩阵,其中只有 2862 个矩阵内容不同,另外 709 次可以复用已有结果,重复率为 19.85%。

随后同时启用状态对称合并和矩阵缓存,共有 2231 个 canonical (State, day) value,只产生了 1832 次实际 LP 调用(因为多个不同状态构造出了相同的收益矩阵),缓存数据量约为 1.12 MiB,完全可以接受。

未采取的策略

_next_states() 没有缓存,测试发现它大约被调用 139 万次,其中 783402 个输入互不相同,虽然能够节省 44% 的重复调用,但由于每项结果还可能包含多个 State,保存 78 万余组后继状态空间成本太高,得不偿失。

线性规划调用彼此独立,因此还测试了线程并行求解,仍然以 D=3 为例:

线程数 运行时间 相对单线程减少
1 4.59 s 0%
2 4.30 s 6.3%
4 4.18 s 8.9%
8 4.00 s 12.9%

收益没有预期的大,因此最终还是只保留了单线程版本。

减少线性规划的固定开销

完成缓存后,剩余时间主要花在许多规模较小、结构相近的线性规划上。SciPy 的 HiGHS 默认执行 presolve,先删除冗余约束并缩小问题。这个步骤通常有利于大型 LP;当前任务中的单个矩阵较小,presolve 的准备时间反而有点浪费。

将求解器设置为 options={'presolve': False},直接处理原始约束。这个不影响正确性,测试发现 presolve 开关的最大 value 差异为 \(1.665\times10^{-15}\).

假设三名角色初始区域独立均匀随机,测试发现确实能省不少时间:

总回合数 \(D\) 开启 presolve 关闭 presolve 耗时减少
3 6.58 s 5.09 s 22.6%
4 15.98 s 11.03 s 31.0%
5 27.18 s 17.39 s 36.0%
6 40.17 s 25.23 s 37.2%
7 52.80 s 32.18 s 39.1%
8 64.16 s 39.07 s 39.1%

presolve_performance
图 2:开启和关闭 presolve 的运行时间。上图为绝对运行时间,下图为耗时减少比例

胜率分析

下面比较三种初始位置下 Alice 的最优胜率:

  1. Brain 与 Killer 分别位于 \(x=(0,1),y=(1,0)\)
  2. 三名角色均位于同一区域即 \(x=y=(0,0)\)
  3. 16 种初始相对位置平均情况。
总回合数 \(D\) \(x=(0,1),y=(1,0)\) \(x=y=(0,0)\) 随机初始平均
3 14.51% 13.43% 13.39%
4 26.74% 25.91% 26.01%
5 41.34% 39.36% 40.60%
6 55.42% 53.53% 55.13%
7 67.67% 66.08% 67.43%
8 77.37% 76.08% 77.16%

win_rates
图 3:不同初始位置下 Alice 的最优胜率。上图为绝对胜率,下图为两个固定局面相对随机初始平均值的百分点差

显然,剩余回合数是影响胜率的主要因素。三条曲线的变化趋势基本一致,说明对于初始位置的依赖较小。

这里有个稍微反直觉的结论,初始区域全部相同反而胜率略低,我认为原因是,在真实的对局中,由于 Bob 不仅要防范主线“谋杀计划”,还需要提防其他失败条件,不得不将牌打在其他地方,而在仅考虑“谋杀计划”的情况下,Bob 有充足的牌拉走角色,而 Alice 没有“禁止移动”牌,难以让他们待在一起。

以上混合策略将在 惨剧轮回自助模拟器 实装,欢迎有权限的玩家前来体验!

posted @ 2026-08-30 19:33  emptyset  阅读(34)  评论(0)    收藏  举报