每日算法 — 使用java实现大富翁:破产概率马尔可夫链与贝尔曼最优购置决策

一、大富翁里真正的算法:不是掷骰子,而是”该不该买地”

大富翁(Monopoly)看起来是个运气游戏,但认真玩过的人都知道,胜负关键在地产购置决策:同样一块地,早买会削弱现金流,晚买会被对手垄断;买哪一排、什么时候抵押、什么时候死守现金——这些决策的质量决定了 80% 的胜负。

把这个问题形式化,会得到两个彼此咬合的算法问题:

  1. 破产概率:给定当前现金、地产与租金流,未来 20 回合内破产的概率是多少?这不是模拟能精确回答的,需要吸收马尔可夫链的首次 passage 概率(first passage probability)。
  2. 最优购置:给定一处地产的价格、租金、垄断加成,买下它带来的期望净收益是多少?这是一个贝尔曼方程(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 为什么是”贝尔曼”而不是”贪心”

贪心做法是”租金/价格比最高的就买”。它会犯两个系统性错误:

  1. 忽略时间价值:一块租金高但需要 15 回合回本的地,在只剩 8 回合的残局里毫无价值;γ^t 正好刻画这一点。
  2. 忽略垄断的阶跃效应:同色系第 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),才得到一个真正可用的决策函数:收益归贝尔曼,风险归马尔可夫链,权衡归 λ。