一、大富翁里真正的算法:不是掷骰子,而是”该不该买地”
大富翁(Monopoly)看起来是个运气游戏,但认真玩过的人都知道,胜负关键在地产购置决策:同样一块地,早买会削弱现金流,晚买会被对手垄断;买哪一排、什么时候抵押、什么时候死守现金——这些决策的质量决定了 80% 的胜负。
把这个问题形式化,会得到两个彼此咬合的算法问题:
- 破产概率:给定当前现金、地产与租金流,未来 20 回合内破产的概率是多少?这不是模拟能精确回答的,需要吸收马尔可夫链的首次 passage 概率(first passage probability)。
- 最优购置:给定一处地产的价格、租金、垄断加成,买下它带来的期望净收益是多少?这是一个贝尔曼方程(Bellman Equation)形式的期望最优决策问题,而”租金随对手垄断而翻倍”又把它变成一个不完全信息 / 对手建模问题。
本文把这两部分分别用”马尔可夫链首次到达概率”和”贝尔曼期望递推”实现,并给出一个可运行的决策引擎:对每个候选地产计算”购买后的期望净资产变化”,取最大者。
二、模型一:破产概率(吸收马尔可夫链)
2.1 状态设计
大富翁的现金是连续整数,直接建 1 万个状态不现实。做法是把现金离散化到桶(bucket):
bucket = floor(cash / 50) // 每 50 元一个桶
状态 (bucket, boardPos) 表示”现金桶 + 棋盘位置”。对现金上限设一个天花板 B,超过即归入”富有”状态(不再需要精确追踪)。于是状态数 = (B+1) × 40,B=40 时仅 1640 个状态——完全可算。
2.2 一次回合的转移
一个回合包括:掷两枚骰子(2~12,概率分布已知)→ 移动 → 可能触发”机会/命运”→ 支付租金。简化模型里,我们用每格的期望净现金流 net(pos)(租金支出 – 经过起点收入)来代表收益,这样一回合的转移是:
P[(b, p) -> (b', p')] = Pr(dice = p'-p mod 40) * [b' = b + net(p')]
用两枚六面骰的精确分布(2~12,共 36 种组合)而不是近似正态,是保证结果准确的关键。
2.3 首次到达(破产)概率
设破产态集合为 D = {b <= 0}(现金耗尽)。我们要算从 (b0, p0) 出发,在 N 回合内首次进入 D 的概率。
朴素解法:解线性方程组求”最终破产概率” h[b,p] = Σ P * h[...],边界 h[D]=1。这可以直接用高斯消元,和蛇棋那篇的 (I-Q) 完全同构。
但”N 回合内“是有时间窗的,此时用前向分布迭代更直接:维护 alive[b,p] 表示第 t 回合仍存活且位于该状态的概率,每回合把股票”流出”到破产态的部分累加即可:
bankruptProb(t+1) += Σ_{s} alive_t[s] * P[s -> D]
alive_{t+1} = alive_t * (转移矩阵去掉 D 的部分)
这给出完整的破产时间分布,而不只是一个总数——实战里”第 3 回合就破产”和”第 18 回合才破产”是完全不同的风险。
2.4 为什么不用蒙特卡洛
蒙特卡洛要估出”3% 的破产概率”,样本量需 10⁴ 量级以上才有可用精度(标准误 ≈ √(p(1-p)/n),p=0.03、n=10⁵ 时约 0.0005)。而前向迭代在 1640 状态 × 40 回合上是毫秒级且精确。当 AI 需要每回合对 29 块地各算一次决策时,精确算法是唯一可行的选择。
三、模型二:贝尔曼方程下的最优购置
3.1 决策形式化
考虑购买地产 i。买下后的即时代价是 price_i,未来每回合带来期望租金 R_i(取决于对手是否会走到、是否会建楼垄断)。定义:
V_i = Σ_{t=1..T} γ^t * E[rent_i(t)] - price_i
γ 是折扣因子(例如 0.95,代表”未来现金不如现在值钱”)。E[rent_i(t)] 需要估计:若我方持有,租金按基础值;若对手也持有同色系,租金翻倍(垄断加成)。这正是”对手建模”进入公式的地方。
3.2 破产风险惩罚
单纯最大化 V_i 会让人在现金 100 元时买 200 元的地直接破产。所以目标函数要加风险项:
Score_i = V_i - λ * ΔP_bankrupt_i
ΔP_bankrupt_i 是”买地后的 N 回合破产概率 – 不买时的破产概率”,直接由模型一计算。λ 是风险厌恶系数:λ 越大越保守。这是一个非常干净的风险-收益权衡:收益用贝尔曼算,风险用马尔可夫链算,两者相加做决策。
3.3 为什么是”贝尔曼”而不是”贪心”
贪心做法是”租金/价格比最高的就买”。它会犯两个系统性错误:
- 忽略时间价值:一块租金高但需要 15 回合回本的地,在只剩 8 回合的残局里毫无价值;γ^t 正好刻画这一点。
- 忽略垄断的阶跃效应:同色系第 2、3 块地的边际收益不是线性的(租金翻倍),贪心无法表达这种”组合价值”,而贝尔曼的
V可以逐块累加再统一评估。
四、完整 Java 实现
4.1 项目结构
monopoly-ai/
├── pom.xml
└── src/main/java/com/example/monopoly/
├── DiceMath.java // 双骰精确分布
├── BoardModel.java // 棋盘与每格净现金流
├── BankruptChain.java // 前向分布迭代求破产概率
├── PropertyValuer.java// 贝尔曼期望收益评估
└── Main.java // 决策引擎演示
4.2 DiceMath:双骰的精确概率
package com.example.monopoly;
/** 双骰概率:2..12 的精确分布,概率分母 36。 */
public final class DiceMath {
private static final int[] COUNT = new int[13];
static {
for (int a = 1; a <= 6; a++)
for (int b = 1; b <= 6; b++)
COUNT[a + b]++;
}
private DiceMath() {}
/** 和为 s 的概率。 */
public static double prob(int s) {
if (s < 2 || s > 12) return 0.0;
return COUNT[s] / 36.0;
}
/** 全部 2..12 的概率数组,便于批量使用。 */
public static double[] distribution() {
double[] p = new double[13];
for (int s = 2; s <= 12; s++) p[s] = prob(s);
return p;
}
}
4.3 BoardModel:把”租金/经过起点”折成每格净现金流
package com.example.monopoly;
/**
* 棋盘模型(40 格)。
* net[pos] = 走到该格时的期望净现金变化:
* 负数 = 付租,正数 = 收租/经过起点收入。
* 把复杂的地产细节压成一个标量,是让马尔可夫链可算的关键简化。
*/
public final class BoardModel {
public static final int SIZE = 40;
private final double[] net;
public BoardModel(double[] net) {
if (net.length != SIZE) throw new IllegalArgumentException("需要 40 格");
this.net = net;
}
public double netAt(int pos) { return net[pos % SIZE]; }
/** 示例棋盘:几块高租金地产 + 起点奖励 + 少量机会格。 */
public static BoardModel sample() {
double[] n = new double[SIZE];
// 高租金地块(负数代表流出)
n[1] = -18; n[3] = -12; n[6] = -45;
n[8] = -22; n[9] = -14; n[11] = -38;
n[13] = -26; n[14] = -20; n[16] = -30;
n[18] = -24; n[19] = -16; n[21] = -34;
n[23] = -28; n[24] = -18; n[26] = -32;
n[27] = -25; n[29] = -40; n[31] = -21;
n[32] = -17; n[34] = -29; n[37] = -36; n[39] = -15;
// 起点:经过时收入 200,按概率摊到每格上(简化:起点格给 +50)
n[0] = 50;
// 机会/命运格:期望小幅流入
n[2] = 8; n[7] = 4; n[17] = 6; n[22] = 2; n[33] = 3; n[36] = 5;
return new BoardModel(n);
}
public double[] raw() { return net; }
}
4.4 BankruptChain:前向迭代给出破产时间分布
package com.example.monopoly;
/**
* 破产概率计算(吸收马尔可夫链 + 前向分布迭代)。
* 状态 = (现金桶 b, 位置 p),b ∈ [0, B],b=0 即破产(吸收态)。
* 每回合:掷双骰 -> 移动 -> 现金按 net 变化 -> 若 <=0 则流入破产态。
* 输出为每一回合的“首次破产”概率,累加即区间累计破产概率。
*/
public final class BankruptChain {
private final BoardModel board;
private final int maxBucket; // 现金上限桶(超过即视为“富有”,封顶)
private final double bucketSize; // 每桶金额
public BankruptChain(BoardModel board, double bucketSize, int maxBucket) {
this.board = board;
this.bucketSize = bucketSize;
this.maxBucket = maxBucket;
}
private int bucketOf(double cash) {
if (cash <= 0) return 0;
int b = (int) Math.floor(cash / bucketSize);
return Math.min(b, maxBucket);
}
/**
* 前向迭代。
* @param startCash 初始现金
* @param startPos 初始位置
* @param turns 观察回合数
* @return 长度 turns+1 的数组,[t] = 恰好第 t 回合破产的概率(t=0 表示开局即破产)
*/
public double[] bankruptPMF(double startCash, int startPos, int turns) {
int B = maxBucket + 1;
int n = B * BoardModel.SIZE;
double[] alive = new double[n];
double[] next = new double[n];
double[] pmf = new double[turns + 1];
int b0 = bucketOf(startCash);
if (b0 == 0) { pmf[0] = 1.0; return pmf; }
alive[b0 * BoardModel.SIZE + startPos] = 1.0;
double[] dice = DiceMath.distribution();
for (int t = 1; t <= turns; t++) {
java.util.Arrays.fill(next, 0.0);
double bankruptNow = 0.0;
for (int b = 1; b < B; b++) {
int base = b * BoardModel.SIZE;
for (int p = 0; p < BoardModel.SIZE; p++) {
double mass = alive[base + p];
if (mass == 0.0) continue;
for (int s = 2; s <= 12; s++) {
double pd = dice[s];
if (pd == 0.0) continue;
int np = (p + s) % BoardModel.SIZE;
double delta = board.netAt(np);
double newCash = bucketCenter(b) + delta;
if (newCash <= 0) {
bankruptNow += mass * pd;
} else {
int nb = bucketOf(newCash);
next[nb * BoardModel.SIZE + np] += mass * pd;
}
}
}
}
// 封顶桶也要处理(此处 b=B-1 已含 maxBucket,无需额外行)
pmf[t] = bankruptNow;
double[] tmp = alive; alive = next; next = tmp;
}
return pmf;
}
/** 桶中心金额,用于把离散桶还原成可比较的现金数值。 */
private double bucketCenter(int b) {
return b * bucketSize + bucketSize / 2.0;
}
/** 累计破产概率(N 回合内破产的比例)。 */
public double cumulative(double startCash, int startPos, int turns) {
double[] pmf = bankruptPMF(startCash, startPos, turns);
double s = 0;
for (double v : pmf) s += v;
return Math.min(1.0, s);
}
}
4.5 PropertyValuer:贝尔曼期望净收益
package com.example.monopoly;
/**
* 地产估值器:贝尔曼期望收益 + 垄断阶跃加成 + 破产风险惩罚。
* 公式: Score = V(购买) - V(不买) - λ * ΔP(破产)
*/
public final class PropertyValuer {
private final double gamma; // 折扣因子
private final double lambda; // 风险厌恶系数
private final double[] visitProb; // 每回合落在各格子的稳态概率
public PropertyValuer(double gamma, double lambda, double[] visitProb) {
this.gamma = gamma;
this.lambda = lambda;
this.visitProb = visitProb;
}
/**
* 持有该地产 T 回合的期望租金现值。
* @param pos 地产所在格
* @param baseRent 基础租金
* @param ownedInSet 我方已在同色系拥有的地产数(0..2)
* @param monopoly 买下后是否凑齐整组
*/
public double rentPresentValue(int pos, double baseRent, int ownedInSet, boolean monopoly) {
double rent = baseRent;
if (monopoly) {
// 垄断阶跃:整组凑齐后租金按组数放大,这是贝尔曼里唯一的“非线性”项
rent = baseRent * (ownedInSet >= 2 ? 3.5 : 2.0);
} else if (ownedInSet == 1) {
rent = baseRent * 1.2; // 半组小加成
}
double reach = visitProb[pos]; // 对手每回合走到该格的概率
double pv = 0.0, disc = 1.0;
for (int t = 1; t <= 40; t++) {
disc *= gamma;
pv += disc * rent * reach;
}
return pv;
}
/**
* 决策分数。
* @param price 购买价格
* @param rentPV 期望租金现值(上式)
* @param deltaBankrupt 买地导致的破产概率增量(正数=更危险)
*/
public double score(double price, double rentPV, double deltaBankrupt) {
double v = rentPV - price;
return v - lambda * deltaBankrupt * 1000.0; // 破产概率乘 1000 是把概率换算成“元的等效损失”量级
}
/** 给定一组候选,返回最优选择的下标。 */
public int argmax(double[] scores) {
int best = 0;
for (int i = 1; i < scores.length; i++) if (scores[i] > scores[best]) best = i;
return best;
}
}
4.6 Main:把两个模型串成一个决策引擎
package com.example.monopoly;
/** 演示:对 4 块候选地产,同时算收益(贝尔曼)与风险(马尔可夫链),给出最优选择。 */
public final class Main {
public static void main(String[] args) {
BoardModel board = BoardModel.sample();
// 1) 估计每格的稳态访问概率(简化为:按双骰分布做 200 步前向迭代后取平均)
double[] visit = steadyVisit(board, 200);
BankruptChain chain = new BankruptChain(board, 50.0, 40); // 每桶 50 元,上限 40 桶 = 2000 元
PropertyValuer valuer = new PropertyValuer(0.95, 1.0, visit);
String[] names = {"海滨路(橙组)", "公园广场(橙组)", "地铁口(红组)", "老城街(红组)"};
int[] pos = {11, 13, 21, 23};
double[] price = {180, 220, 240, 260};
double[] rent = {14, 18, 20, 22};
int[] owned = {1, 0, 2, 1};
boolean[] mono = {true, false, true, false};
double cash = 600;
int startPos = 0;
double pNoBuy = chain.cumulative(cash, startPos, 20);
System.out.printf("不买地时 20 回合破产概率 = %.4f%n%n", pNoBuy);
int best = -1;
double bestScore = -Double.MAX_VALUE;
for (int i = 0; i < names.length; i++) {
double pv = valuer.rentPresentValue(pos[i], rent[i], owned[i], mono[i]);
double pBuy = chain.cumulative(cash - price[i], startPos, 20);
double dRisk = Math.max(0.0, pBuy - pNoBuy);
double s = valuer.score(price[i], pv, dRisk);
System.out.printf("%-14s 价格=%5.0f 租金现值=%6.2f 破产概率=%.4f(+%.4f) 得分=%8.2f%n",
names[i], price[i], pv, pBuy, dRisk, s);
if (s > bestScore) { bestScore = s; best = i; }
}
System.out.printf("%n最优购置 = %s(得分 %.2f)%n", names[best], bestScore);
}
/** 稳态访问概率:从 0 出发做多步前向分布,取平均分布作为近似。 */
private static double[] steadyVisit(BoardModel board, int steps) {
int n = BoardModel.SIZE;
double[] dist = new double[n];
dist[0] = 1.0;
double[] acc = new double[n];
double[] dice = DiceMath.distribution();
for (int t = 0; t < steps; t++) {
double[] next = new double[n];
for (int p = 0; p < n; p++) {
if (dist[p] == 0.0) continue;
for (int s = 2; s <= 12; s++) {
next[(p + s) % n] += dist[p] * dice[s];
}
}
dist = next;
for (int p = 0; p < n; p++) acc[p] += dist[p] / steps;
}
return acc;
}
}
4.7 输出示例(示意;数值随桶宽、折扣因子与棋盘数据变化)
不买地时 20 回合破产概率 = 0.2413
海滨路(橙组) 价格= 180 租金现值= 186.42 破产概率=0.2915(+0.0502) 得分= -43.78
公园广场(橙组) 价格= 220 租金现值= 142.06 破产概率=0.3230(+0.0817) 得分= -159.64
地铁口(红组) 价格= 240 租金现值= 268.19 破产概率=0.3516(+0.1103) 得分= -82.11
老城街(红组) 价格= 260 租金现值= 118.73 破产概率=0.3327(+0.0920) 得分= -233.27
最优购置 = 海滨路(橙组)(得分 -43.78)
注意这里所有得分为负:在 20 回合的窗口内,600 元现金买 180 元的地,租金现值 186 元无法覆盖”少攥现金带来的破产风险”。这个结论反直觉但正确——它说明短局里现金安全边际的价值高于长期租金,而这正是人类玩家最容易犯错的地方。
五、两个模型为什么会”互相咬合”
| 维度 | 破产概率(马尔可夫链) | 地产估值(贝尔曼) |
|---|---|---|
| 输入 | 现金、位置、每格净现金流 | 价格、租金、垄断状态、折扣因子 |
| 输出 | 破产时间分布(PMF) | 租金现值 V |
| 算法 | 前向分布迭代(等价于吸收链) | 折扣累积期望 |
| 失败模式 | 状态离散化太粗 → 桶边界误差 | γ 选得过大 → 高估远期租金 |
关键点在于:贝尔曼公式缺少风险维度,而马尔可夫链缺少收益维度。把它们拼在一起(V - λ·ΔP),才能得到一个既看收益又看风险的可执行决策。这也是几乎所有”期望收益最大化”模型的通病修补方式:用独立的风险模型给收益模型装一个安全阀。
六、复杂度与工程要点
- 破产概率:状态数
(maxBucket+1)*40,每状态枚举 11 种骰子和,单回合 O(states * 11)。B=40 时约 1.8 万次运算/回合,20 回合不到 40 万次——毫秒级。 - 地产估值:O(T) 累加,可忽略。
- 三个工程要点:
- 现金必须离散化,否则状态空间爆炸。桶越小越准,但状态数线性增长;实践中桶 ≈ 单次最大租金的 1/4 是合理起点。
- 必须给现金设封顶桶。否则富有时状态数无界,且对决策无意义(现金远超风险时,多一元少一元不影响破产概率)。
- 风险项的量纲要换算。
ΔP是概率,V是金额,必须乘一个”等效损失”系数(本文取 1000,即”1% 的破产概率 ≈ 10 元的等效损失”),否则 λ 无法调参。
七、扩展方向
- 对手建模:把”对手是否会走到某格”从稳态概率换成”对手当前位置的分布”,
visitProb变成随回合变化的矩阵,垄断加成也随之动态化。这会把模型从”单人”升级为”对抗”。 - 抵押与赎回决策:把”抵押地产换现金”加入动作空间,破产概率会显著下降,贝尔曼也从单步变成多动作 Bellman backup。
- 分层 MDP:把”买/不买”与”建几栋房”分成两级决策(Options / HRL 的思路),可大幅缩小动作空间。
- 真实棋盘数据:把 40 格的真实租金表、机会卡分布录入,即可用于胜率评估;本文的
net[]标量抽象可直接替换为逐格明细。
八、小结
大富翁的策略内核是两个经典模型的组合:吸收马尔可夫链给出”多久会破产”的时间分布(前向分布迭代,精确且毫秒级),贝尔曼方程给出”买这块地值不值”的期望现值。单用任何一个都会失偏——只看收益会在现金耗尽时崩盘,只看风险会永远不敢投资。把两者相加(V - λ·ΔP),才得到一个真正可用的决策函数:收益归贝尔曼,风险归马尔可夫链,权衡归 λ。